Skip to main content

max / alloy

Add a vmtest that boots an installed machine with no route alloy@aa0dacd fixed the offline first boot and was verified by hand: nothing in build/vmtest would have caught it coming back. This is that check, written down. offline-first-boot.sh installs a machine, takes the guest's link down from the monitor before the first boot lays the components down, and asserts that /usr/bin/alloy exists afterwards and that the enabled repo set is alloy-local plus Fedora's four. Red before aa0dacd, green after: the failing shape is that the unit fails, so it never reaches its `systemctl reboot`, so no RESET arrives and there is no console. check-installed.sh runs at the end, folded in rather than left separate because it had never been run against an install from this harness either and a scenario holding a fresh machine is already holding everything it needs. Three pieces the harness did not have. qmp.py, the machine monitor next to vm.py's human one. HMP cannot say WHEN something happened inside the guest: `info status` reports running on both sides of a reset, so a script watching the clock or the qcow2's size is guessing. QMP emits RESET, and filtering on `guest: true` makes it the question actually being asked. set_link is here for the same reason it is not in vm.py: from inside, `ip link set <dev> down` travels over the ssh session it arrived on and kills it, which is why the hand run needed a detached setsid script to get home. tui.py, which reads the screen rather than the bytes. ratatui redraws only the cells that changed, so `step 1 of 6` becoming `step 2 of 6` puts a single `2` on the wire behind a cursor move: grepping the stream finds a title exactly once, on the first frame, and then silently never again. So it keeps a grid and applies crossterm's escape subset to it, with a --self-test over ten shapes because a wrong parse still produces a screen. install_drive.py, which walks the six questions over the medium's own ssh installer account rather than sending scancodes at the monitor. Reading each step's title back is what makes a slow install and a stuck one different. It answers encryption off, and picks the disk by stepping and reading the gutter marker instead of assuming an ordering. run-vm.sh gains the QMP socket and an id on the NIC so set_link can name it. GO alloy cd2da818.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 16:11 UTC
Signed with PGP, not checked
Commit: 3cd023a79ac2f663bc1f9bc3be11db042c3c2d9b
Parent: c266919
7 files changed, +1250 insertions, -6 deletions
M docs/STACK.md +1 -1
@@ -48,7 +48,7 @@
48 48
49 49 That repo is enabled, and it is forced rather than chosen: `rpm-ostree install` on a booted system supports `--enablerepo` only in a container build, so a repo shipped disabled is one the first-boot unit could not turn on for its own transaction. It reaches no network, which is why this does not touch the position below.
50 50
51 - **And the first boot is fenced to it, or the carried repo buys nothing.** rpm-ostree refreshes metadata for every *enabled* repo before it depsolves, so with Fedora's four enabled the unit needed name resolution to install packages that were already on the disk: measured 2026-08-25 on an installed server-profile machine, where the first boot failed on `Could not resolve hostname for mirrors.fedoraproject.org` and the machine came up with no console. That is the offline install this design exists to make work. Neither flag can fix it, since `--disablerepo` is refused outside a container build exactly as `--enablerepo` is, and `--cache-only` refreshes nothing at all and cannot see the carried repo on a machine that has never refreshed. So `usr/bin/alloy-layer-repos` disables every other repo for the length of that one transaction and puts `/etc` back afterwards from the image's own copy under `/usr/etc`. The deployment it stages does not inherit the disabled files, measured by rebooting into it. Which repos an installed machine leaves enabled is deliberately unchanged: `alloy pkg` layers Fedora packages, and a machine that shipped them disabled would quietly find nothing.
51 + **And the first boot is fenced to it, or the carried repo buys nothing.** rpm-ostree refreshes metadata for every *enabled* repo before it depsolves, so with Fedora's four enabled the unit needed name resolution to install packages that were already on the disk: measured 2026-08-25 on an installed server-profile machine, where the first boot failed on `Could not resolve hostname for mirrors.fedoraproject.org` and the machine came up with no console. That is the offline install this design exists to make work. Neither flag can fix it, since `--disablerepo` is refused outside a container build exactly as `--enablerepo` is, and `--cache-only` refreshes nothing at all and cannot see the carried repo on a machine that has never refreshed. So `usr/bin/alloy-layer-repos` disables every other repo for the length of that one transaction and puts `/etc` back afterwards from the image's own copy under `/usr/etc`. The deployment it stages does not inherit the disabled files, measured by rebooting into it. Which repos an installed machine leaves enabled is deliberately unchanged: `alloy pkg` layers Fedora packages, and a machine that shipped them disabled would quietly find nothing. `build/vmtest/offline-first-boot.sh` is what keeps this measured rather than remembered: it installs a machine, takes the guest's link down from the monitor before the first boot, and asserts both ends of the paragraph above: that a console got laid down with no route, and that the enabled repo set afterwards is the ordinary one.
52 52
53 53 **The network repo ships disabled, and that is chosen rather than forced.** The carried `file://` repo above had to be enabled; this one did not, and it is `enabled=0` on every installed machine. A fresh Alloy has no line pointing at makenot.work that its owner did not add. Turning it on is one command and needs no extra package:
54 54
@@ -1,9 +1,15 @@
1 1 # vmtest — driving the installer in qemu
2 2
3 - Four scripts that boot an Alloy ISO headless and work the wizard from outside
4 - the guest. They exist because `alloy install` is a ratatui program with no
5 - headless mode, so the only way to test the thing an installer medium actually
6 - does is to send it keystrokes and read the screen back.
3 + Scripts that boot an Alloy ISO headless and work the wizard from outside the
4 + guest. They exist because `alloy install` is a ratatui program with no headless
5 + mode and no answer file, so the only way to test the thing an installer medium
6 + actually does is to answer its questions and read the screen back.
7 +
8 + Two halves, and they answer different questions. The tools (`run-vm.sh`,
9 + `vm.py`, `serial.py`, `ssh_pty.py`, `tui.py`, `qmp.py`) are for working a
10 + machine by hand, which is what most of this file is about. The scenarios
11 + (`install_drive.py`, `offline-first-boot.sh`) are the same work written down,
12 + so a defect that has been fixed once has something standing in front of it.
7 13
8 14 Everything a run writes goes to `state/`, which is gitignored. Delete it to
9 15 start clean. `build/build-iso.sh` clears `output/` at the start of every build,
@@ -27,12 +33,35 @@
27 33 python3 vm.py shot name # screendump to state/name.png
28 34 python3 serial.py "lsblk" 8 # run a command in the guest's serial root shell
29 35 python3 ssh_pty.py ~/.ssh/id_ed25519 installer 30
36 + python3 qmp.py cmd query-status
37 + python3 qmp.py wait RESET 900 '{"guest": true}'
38 + python3 tui.py 60 'step 1 of 6' ssh -tt -p 2222 installer@127.0.0.1
39 +
40 + ./offline-first-boot.sh # the scripted scenario; see below
30 41
31 42 `vm.py shot` converts qemu's PPM to PNG with nothing but zlib and struct, so
32 43 the installer screen can be read directly with no image tooling installed.
33 44 `serial.py` reaches a root shell only on GRUB entry 3, which is the debug
34 45 entry; the default entry runs the wizard and has no shell.
35 46
47 + `qmp.py` is the machine monitor, next to `vm.py`'s human one rather than
48 + instead of it. HMP is right for sendkey and screendump; QMP is the only one
49 + that can say WHEN something happened inside the guest, because it emits events.
50 + `set_link` lives there too: taking the guest's route away from outside is the
51 + only way that does not depend on the guest cooperating, and a `RESET` with
52 + `guest: true` is how "the machine rebooted itself" becomes a thing to wait for
53 + rather than a thing to infer from the clock.
54 +
55 + `tui.py` reads the screen rather than the bytes, and the difference is not a
56 + refinement. ratatui redraws only the cells that changed, so `step 1 of 6`
57 + becoming `step 2 of 6` puts a single `2` on the wire behind a cursor move: a
58 + script grepping the stream finds the title it is waiting for exactly once, on
59 + the first frame where everything is written, and then silently never again.
60 + `ssh_pty.py` captures the stream and is the right tool for looking at one;
61 + anything that has to make a decision from what is on screen wants this. It
62 + carries a `--self-test` for the parser, for the same reason
63 + `check-installed.sh` carries one: a wrong parse still produces a screen.
64 +
36 65 ## Things that cost an afternoon to learn
37 66
38 67 **Reset between runs by deleting `state/target.qcow2` and `state/OVMF_VARS.fd`
@@ -115,6 +144,58 @@
115 144 `--permanent --list-services` on the live medium shows the runtime add is not
116 145 written anywhere.
117 146
147 + ## The scripted scenario: an offline first boot
148 +
149 + `./offline-first-boot.sh` installs a machine, takes its route away before the
150 + first boot, and checks that the console gets laid down anyway.
151 +
152 + It exists because that defect shipped. `alloy-layer-components.service`
153 + installs the console and terminal from `/usr/share/alloy/rpm`, a `file://`
154 + repo carried on the medium precisely so an offline install produces a working
155 + machine, and it did not: rpm-ostree refreshes metadata for every ENABLED repo
156 + before it depsolves, so with Fedora's four enabled a first boot with no name
157 + resolution failed on a mirrorlist it did not need, and the machine came up with
158 + no console at all. Fixed in `aa0dacd` by fencing the transaction to the carried
159 + repo (`usr/bin/alloy-layer-repos`), verified by hand, and until this script
160 + nothing here would have caught it coming back.
161 +
162 + Red before `aa0dacd`, green after. The failing shape is specific: the unit
163 + fails, so it never reaches its `systemctl reboot`, so no `RESET` arrives and
164 + `/usr/bin/alloy` is absent afterwards.
165 +
166 + It needs an ISO whose baked pubkey matches the key it is given, because the
167 + installer is driven over the medium's own ssh account and that key is its only
168 + credential:
169 +
170 + build/build-iso.sh --build-arg ALLOY_SSH_KEY="$(cat ~/.ssh/id_ed25519.pub)"
171 + build/vmtest/offline-first-boot.sh
172 +
173 + Four things are asserted, and the last is folded in rather than separate
174 + because a scenario that boots a fresh machine is already holding everything
175 + `check-installed.sh` needs:
176 +
177 + 1. the machine rebooted itself, which is the unit's last act
178 + 2. `/usr/bin/alloy` exists
179 + 3. the enabled repo set is `alloy-local` plus Fedora's four, so the fence was
180 + lowered again and `alloy pkg` will still find Fedora packages
181 + 4. `check-installed.sh` is clean on the result
182 +
183 + Exit 0 is a machine that survived, 1 is one that did not with the evidence
184 + printed, and 3 means the run could not reach a verdict.
185 +
186 + **The route is taken away from the monitor, not from inside.** `ip link set
187 + enp0s2 down` travels over the ssh session it arrived on and kills it, which is
188 + why the hand run needed a detached `setsid` script that brought the interface
189 + back up at the end. From outside, the link is a property of the device and the
190 + guest gets no say. It goes back up only after the reboot, where it cannot
191 + affect the answer: the unit's `ConditionPathExists=!/usr/bin/alloy` is already
192 + false on a machine that has a console.
193 +
194 + **A machine that has already been first-booted is not a run of this test.** The
195 + unit is a no-op there, so no reboot happens and no reboot SHOULD happen. The
196 + script reports that as exit 3 rather than as a regression, which is the one
197 + thing `--keep-state` could otherwise turn into a false alarm.
198 +
118 199 ## What it caught
119 200
120 201 The two defects fixed in `crates/alloy/src/install.rs` on 2026-08-09: the
@@ -44,7 +44,12 @@
44 44 # Refused there while ssh answers on :2222 is what the zone claims. See
45 45 # build/vmtest/README.md, "Checking the firewall".
46 46 -netdev "user,id=n0,hostfwd=tcp:127.0.0.1:2222-:22,hostfwd=tcp:127.0.0.1:${PROBE_PORT:-2223}-:5555"
47 - -device virtio-net-pci,netdev=n0
47 + # `id=` so the frontend can be named from outside. QMP's `set_link` takes a
48 + # device id, and taking the guest's route away from the monitor is the only
49 + # way to do it that does not depend on the guest: from inside, `ip link set
50 + # <dev> down` travels over the ssh session it arrived on and kills it. See
51 + # build/vmtest/qmp.py and offline-first-boot.sh.
52 + -device virtio-net-pci,netdev=n0,id=nic0
48 53 -chardev "socket,id=chrtpm,path=$SCRATCH/tpm/swtpm-sock"
49 54 -tpmdev emulator,id=tpm0,chardev=chrtpm
50 55 -device tpm-crb,tpmdev=tpm0
@@ -52,6 +57,12 @@
52 57 -chardev "socket,id=ser0,path=$SCRATCH/serial.sock,server=on,wait=off,logfile=$SCRATCH/serial-$MODE.log"
53 58 -serial chardev:ser0
54 59 -monitor "unix:$SCRATCH/monitor.sock,server,nowait"
60 + # The machine monitor, next to the human one rather than instead of it. HMP
61 + # is what vm.py wants (sendkey, screendump); QMP is the only one that can say
62 + # WHEN something happened inside the guest, because it emits events. A
63 + # scripted first-boot test needs to know the machine rebooted, and `info
64 + # status` reports running on both sides of a reset. See build/vmtest/qmp.py.
65 + -qmp "unix:$SCRATCH/qmp.sock,server=on,wait=off"
55 66 )
56 67
57 68 if [ "$MODE" = live ]; then
@@ -1,0 +1,250 @@
1 + #!/usr/bin/env python3
2 + """Walk `alloy install` to the end, over the medium's own ssh installer.
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. The
6 + medium already offers the right way in: `alloy-installer-ssh.service` creates
7 + the `installer` account on the live medium only, and the sshd drop-in makes
8 + that account's session BE `alloy install` under a real tty. So this connects as
9 + that account and reads the frames it draws, rather than sending scancodes at
10 + the qemu monitor and screendumping to find out what happened.
11 +
12 + That choice is not only convenience. Reading the screen as text is what makes
13 + each step's completion a fact rather than a delay: every wait below is for a
14 + title the installer only draws once it is on that step, so a slow install and a
15 + stuck one look different. The screendump route can only compare pictures.
16 +
17 + The answers are the ones a regression test wants and not the ones a person
18 + would pick:
19 +
20 + encryption OFF The unlock path is a whole second thing to go wrong, and
21 + nothing downstream of here touches LUKS. `--encrypt` turns
22 + it back on for a run that means to exercise it.
23 + a pubkey The installed machine has to be reachable afterwards or
24 + nothing can be asserted about it. Same key as the medium's,
25 + because there is no reason for a scenario to hold two.
26 +
27 + Use:
28 +
29 + install_drive.py --key ~/.ssh/id_ed25519 --user tester --hostname alloytest
30 +
31 + Exits 0 when the install reports success, 1 when it reports failure, and 3 when
32 + the run could not get far enough to have a verdict. The screen is printed on
33 + every path, because a failed install's own error is the thing worth reading and
34 + it is on it.
35 + """
36 +
37 + import argparse
38 + import os
39 + import re
40 + import sys
41 + import time
42 +
43 + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
44 + from tui import Session, Timeout # noqa: E402
45 +
46 + SCRATCH = os.environ.get(
47 + "VM_STATE", os.path.join(os.path.dirname(os.path.abspath(__file__)), "state")
48 + )
49 +
50 + ENTER = "\r"
51 + TAB = "\t"
52 + SPACE = " "
53 +
54 +
55 + def ssh_argv(key, user, port):
56 + return [
57 + "ssh", "-tt",
58 + # The medium is rebuilt constantly and always answers on the same
59 + # forwarded port, so a known_hosts entry here is a guaranteed false
60 + # alarm rather than a check.
61 + "-o", "StrictHostKeyChecking=no",
62 + "-o", "UserKnownHostsFile=/dev/null",
63 + "-o", "LogLevel=ERROR",
64 + "-o", "ConnectTimeout=10",
65 + "-p", str(port), "-i", key, "%s@127.0.0.1" % user,
66 + ]
67 +
68 +
69 + def connect(key, port, timeout):
70 + """Keep trying until the medium's sshd is up, or give up saying so.
71 +
72 + A live ISO takes tens of seconds to reach multi-user, and the account this
73 + logs in as is created by a unit inside that boot, so early attempts are
74 + expected to fail. What is not expected is all of them failing, which is
75 + what the deadline turns into a message."""
76 + deadline = time.time() + timeout
77 + last = ""
78 + while time.time() < deadline:
79 + s = Session(ssh_argv(key, "installer", port),
80 + log_path=os.path.join(SCRATCH, "install-drive.raw"))
81 + try:
82 + s.wait_for(r"step 1 of 6", min(45.0, max(5.0, deadline - time.time())))
83 + return s
84 + except Timeout as e:
85 + last = e.screen
86 + s.close()
87 + time.sleep(3)
88 + raise SystemExit(
89 + "error: the installer never drew its first step on port %d.\n"
90 + "The medium has to carry a baked pubkey matching the key given here — "
91 + "build it with `--build-arg ALLOY_SSH_KEY=\"$(cat <key>.pub)\"` — and "
92 + "alloy-installer-ssh.service only creates the account when the kernel "
93 + "command line carries `alloy.installer`, which only the ISO's GRUB "
94 + "entries set.\nThe last thing the session showed:\n\n%s\n" % (port, last)
95 + )
96 +
97 +
98 + MARKER = "\u25b6" # alloy_tui::selection::MARKER, the selected-row gutter mark.
99 +
100 +
101 + def selected_row(s):
102 + """The name on the row the cursor is on, read off the gutter marker.
103 +
104 + AlloyList draws `MARKER` on the selected row and a space on every other, so
105 + which row is selected is on the screen rather than something to be counted.
106 + That is what makes the walk below self-correcting: it checks where it
107 + landed instead of assuming a starting index and an ordering."""
108 + hit = re.search(re.escape(MARKER) + r"\s+(\S+)", s.screen.text())
109 + return hit.group(1) if hit else None
110 +
111 +
112 + def pick_disk(s, want, limit=40):
113 + """Put the cursor on `want`.
114 +
115 + `k` to the top first, because the cursor clamps rather than wrapping
116 + (alloy_tui::Cursor::move_by) so a long enough run lands on row 0 from
117 + anywhere, and because AlloyList derives its scroll offset from the
118 + selection — at the top, what is on screen starts at the first disk.
119 +
120 + Then down one row at a time, reading the marker after each. Stepping and
121 + checking rather than jumping to a counted index is what keeps this honest
122 + about a list longer than the pane."""
123 + s.send("k" * limit, settle=0.8)
124 + seen = []
125 + for _ in range(limit):
126 + here = selected_row(s)
127 + if here is None:
128 + raise SystemExit(
129 + "error: the disk step is showing no selected row.\n\n%s\n" % s.screen.text()
130 + )
131 + if here == want:
132 + return here
133 + if seen and here == seen[-1]:
134 + break # the bottom: the cursor clamped and stopped moving
135 + seen.append(here)
136 + s.send("j", settle=0.4)
137 + raise SystemExit(
138 + "error: no disk called %r on the disk step. The rows it stepped through: %s\n\n%s\n"
139 + % (want, ", ".join(seen) or "(none)", s.screen.text())
140 + )
141 +
142 +
143 + def drive(s, args):
144 + # 1 of 6, the disk.
145 + pick_disk(s, args.disk)
146 + s.send(ENTER)
147 +
148 + # 2 of 6, the machine's name. Enter submits from the name field; the
149 + # timezone checkbox below it has a default and is left at it.
150 + s.wait_for(r"step 2 of 6", 30)
151 + s.send(args.hostname)
152 + s.send(ENTER)
153 +
154 + # 3 of 6, the account. Tab moves between the four fields, and the pubkey is
155 + # the last of them.
156 + s.wait_for(r"step 3 of 6", 30)
157 + s.send(args.user)
158 + s.send(TAB)
159 + s.send(args.password)
160 + s.send(TAB)
161 + s.send(args.password)
162 + s.send(TAB)
163 + s.send(args.pubkey)
164 + s.send(ENTER)
165 +
166 + # 4 of 6, encryption. The checkbox has focus and defaults to on, so a space
167 + # here is what turns it off.
168 + s.wait_for(r"step 4 of 6", 30)
169 + if not args.encrypt:
170 + s.send(SPACE)
171 + else:
172 + s.send(TAB)
173 + s.send(args.passphrase)
174 + s.send(TAB)
175 + s.send(args.passphrase)
176 + s.send(ENTER)
177 +
178 + # 5 of 6, the review. It no longer installs; it advances.
179 + s.wait_for(r"step 5 of 6", 30)
180 + s.send(ENTER)
181 +
182 + # 6 of 6, the credits, which is the step that acts. The first Enter raises
183 + # the erase confirmation and the second answers it — the footer says
184 + # `enter install` and means it two keystrokes from now.
185 + s.wait_for(r"step 6 of 6", 30)
186 + s.send(ENTER)
187 + s.wait_for(r"(?i)erase|confirm", 30)
188 + s.send(ENTER)
189 +
190 +
191 + def wait_for_verdict(s, seconds):
192 + """Wait for the run to finish, and say which way.
193 +
194 + Read off the footer, which is the installer's own statement about its
195 + state: the run screen offers `esc done` only once the sequence is over, and
196 + adds `r reboot` only when it succeeded (InstallView::hints). So the two
197 + outcomes are distinguishable without matching on any message text."""
198 + s.wait_for(r"esc\s+done", seconds)
199 + # One more frame, so a `reboot` hint drawn in the same repaint as `done`
200 + # is not missed by reading between the two writes.
201 + s.pump(1.0)
202 + return "reboot" in s.screen.text()
203 +
204 +
205 + def main():
206 + p = argparse.ArgumentParser(description="drive `alloy install` to the end")
207 + p.add_argument("--key", default=os.path.expanduser("~/.ssh/id_ed25519"),
208 + help="private key matching the pubkey baked into the medium")
209 + p.add_argument("--pubkey", default=None,
210 + help="public key for the account being created (default: --key + .pub)")
211 + p.add_argument("--port", type=int, default=2222)
212 + p.add_argument("--disk", default="vda")
213 + p.add_argument("--hostname", default="alloytest")
214 + p.add_argument("--user", default="tester")
215 + p.add_argument("--password", default="alloytest")
216 + p.add_argument("--encrypt", action="store_true",
217 + help="leave encryption on, and answer its passphrase")
218 + p.add_argument("--passphrase", default="alloytestalloytest")
219 + p.add_argument("--connect-timeout", type=float, default=300.0)
220 + p.add_argument("--install-timeout", type=float, default=1800.0)
221 + args = p.parse_args()
222 +
223 + if args.pubkey is None:
224 + path = args.key + ".pub"
225 + if not os.path.exists(path):
226 + raise SystemExit("error: no %s; pass --pubkey" % path)
227 + args.pubkey = open(path).read().strip()
228 +
229 + s = connect(args.key, args.port, args.connect_timeout)
230 + try:
231 + drive(s, args)
232 + print("==> installing; this is the long part", flush=True)
233 + ok = wait_for_verdict(s, args.install_timeout)
234 + print(s.screen.text())
235 + if not ok:
236 + print("\nerror: the installer finished without offering a reboot, "
237 + "which is how it says the install failed", file=sys.stderr)
238 + return 1
239 + return 0
240 + except Timeout as e:
241 + print("error: %s" % e, file=sys.stderr)
242 + return 3
243 + finally:
244 + # Left running, the ForceCommand session would keep the installer alive
245 + # on a medium the scenario is about to power off.
246 + s.close()
247 +
248 +
249 + if __name__ == "__main__":
250 + sys.exit(main())
@@ -1,0 +1,318 @@
1 + #!/usr/bin/env bash
2 + #
3 + # offline-first-boot.sh — install a machine, take its route away, and check
4 + # that the first boot still lays the console down.
5 + #
6 + # THE REGRESSION THIS EXISTS FOR. alloy-layer-components.service installs the
7 + # console and terminal from /usr/share/alloy/rpm, a file:// repo carried on the
8 + # medium precisely so an offline install produces a working machine. It did
9 + # not: rpm-ostree refreshes metadata for every ENABLED repo before it
10 + # depsolves, and the image leaves Fedora's four enabled, so a first boot with
11 + # no name resolution failed on a mirrorlist it did not need and left the
12 + # machine with no console at all. Fixed in alloy@aa0dacd by fencing the
13 + # transaction to the carried repo (usr/bin/alloy-layer-repos), and verified by
14 + # hand — nothing in build/vmtest would have caught it coming back, which is
15 + # what this closes.
16 + #
17 + # Red before aa0dacd, green after. The failing shape is specific: the unit
18 + # fails, so it never reaches its `systemctl reboot`, so no RESET arrives and
19 + # /usr/bin/alloy is absent afterwards.
20 + #
21 + # ## What it asserts, and why each one is here
22 + #
23 + # 1. the machine rebooted the unit's last act. Its absence IS the
24 + # regression, and waiting for the event rather
25 + # than for the clock is what makes a slow
26 + # install distinguishable from a failed one.
27 + # 2. /usr/bin/alloy exists the console got laid down. The done
28 + # condition of the whole exercise.
29 + # 3. the enabled repo set `alloy-layer-repos on` put /etc back, on the
30 + # deployment the transaction staged. Fencing
31 + # the repos and leaving them fenced would be a
32 + # different defect with the same symptom
33 + # later: `alloy pkg` finding nothing.
34 + # 4. check-installed.sh folded in because it had never been run
35 + # against an install from this harness either,
36 + # and a scenario that boots a fresh machine is
37 + # already holding everything it needs.
38 + #
39 + # ## How the route is taken away
40 + #
41 + # From the monitor, with QMP `set_link`, before the guest has booted at all.
42 + # Not from inside: `ip link set enp0s2 down` travels over the ssh session it
43 + # arrived on and kills it, which is why the hand run on 2026-08-25 needed a
44 + # detached `setsid` script that brought the interface back up at the end. From
45 + # out here the link is a property of the device and the guest gets no say.
46 + #
47 + # The link goes back up only after the reboot, and it cannot affect the result
48 + # by then: the unit's `ConditionPathExists=!/usr/bin/alloy` is false on a
49 + # machine that has a console, so the second boot does not run it. Bringing it
50 + # up is how the assertions get in, and on the failing path it is how the
51 + # evidence gets out.
52 + #
53 + # ## Requirements
54 + #
55 + # Everything build/vmtest/README.md lists, plus an ISO that carries a public
56 + # key matching the private key given here — the installer is driven over the
57 + # medium's own ssh account, whose only credential is that baked key:
58 + #
59 + # build/build-iso.sh --build-arg ALLOY_SSH_KEY="$(cat ~/.ssh/id_ed25519.pub)"
60 + #
61 + # ## Use
62 + #
63 + # build/vmtest/offline-first-boot.sh
64 + # build/vmtest/offline-first-boot.sh --key ~/.ssh/alloy_vm
65 + # build/vmtest/offline-first-boot.sh --keep-state # reuse a finished install
66 + #
67 + # Exit codes follow check-installed.sh and check-rust-stage.sh:
68 + #
69 + # 0 the machine came up with a console after an offline first boot, with the
70 + # ordinary repo set and a correctly labelled /etc.
71 + # 1 it did not. What failed is printed, with the evidence gathered from the
72 + # guest.
73 + # 3 something about the run rather than about the machine: a missing tool, no
74 + # ISO, no key, or an install that never got far enough to have a verdict.
75 +
76 + set -Eeuo pipefail
77 +
78 + HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
79 + REPO_ROOT="$(cd "$HERE/../.." && pwd)"
80 + SCRATCH="${VM_STATE:-$HERE/state}"
81 + export VM_STATE="$SCRATCH"
82 +
83 + KEY="${VMTEST_KEY:-$HOME/.ssh/id_ed25519}"
84 + ISO="${ALLOY_ISO:-$REPO_ROOT/output/install.iso}"
85 + DISK_NAME="${VMTEST_DISK:-vda}"
86 + HOSTNAME_="${VMTEST_HOSTNAME:-alloytest}"
87 + USER_="${VMTEST_USER:-tester}"
88 + PASSWORD="${VMTEST_PASSWORD:-alloytest}"
89 + PORT="${VMTEST_PORT:-2222}"
90 + KEEP_STATE=0
91 +
92 + # The layering transaction is the long part, and it is the one whose absence is
93 + # the finding. Generous, because a slow host must not read as a regression.
94 + LAYER_TIMEOUT="${VMTEST_LAYER_TIMEOUT:-900}"
95 + INSTALL_TIMEOUT="${VMTEST_INSTALL_TIMEOUT:-1800}"
96 + SSH_TIMEOUT="${VMTEST_SSH_TIMEOUT:-300}"
97 +
98 + QEMU_PID=""
99 +
100 + die() { printf 'error: %s\n' "$*" >&2; exit 3; }
101 + fail() { printf 'FAIL: %s\n' "$*" >&2; FAILED=$((FAILED + 1)); }
102 + say() { printf '==> %s\n' "$*"; }
103 + FAILED=0
104 +
105 + while [ $# -gt 0 ]; do
106 + case "$1" in
107 + --key) KEY="${2:?--key needs a path}"; shift 2 ;;
108 + --iso) ISO="${2:?--iso needs a path}"; shift 2 ;;
109 + --disk) DISK_NAME="${2:?--disk needs a name}"; shift 2 ;;
110 + # Reuse the disk in state/ instead of installing. For iterating on the
111 + # second half without paying for the first every time — and only for a
112 + # disk that has been installed and NOT yet first-booted, since the unit
113 + # under test does nothing on a machine that already has a console. The
114 + # verdict below says so rather than reading that as a regression.
115 + --keep-state) KEEP_STATE=1; shift ;;
116 + -h|--help) sed -n '2,80p' "$0"; exit 0 ;;
117 + *) die "unknown argument: $1" ;;
118 + esac
119 + done
120 +
121 + # ---- preflight ----
122 +
123 + for tool in qemu-system-x86_64 swtpm swtpm_setup python3 ssh scp; do
124 + command -v "$tool" >/dev/null 2>&1 || die "no $tool"
125 + done
126 + [ -f /usr/share/OVMF/OVMF_CODE_4M.fd ] || die "no OVMF at /usr/share/OVMF"
127 + [ -f "$KEY" ] || die "no private key at $KEY"
128 + [ -f "$KEY.pub" ] || die "no public key at $KEY.pub"
129 + [ "$KEEP_STATE" = 1 ] || [ -f "$ISO" ] || die "no ISO at $ISO; build/build-iso.sh first"
130 +
131 + SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
132 + -o LogLevel=ERROR -o ConnectTimeout=10 -o BatchMode=yes
133 + -p "$PORT" -i "$KEY")
134 +
135 + qmp() { python3 "$HERE/qmp.py" "$@"; }
136 + guest() { ssh "${SSH_OPTS[@]}" "$USER_@127.0.0.1" "$@"; }
137 +
138 + boot() {
139 + # run-vm.sh exec's qemu, so this pid is qemu's and killing it is enough.
140 + "$HERE/run-vm.sh" "$1" >"$SCRATCH/qemu-$1.log" 2>&1 &
141 + QEMU_PID=$!
142 + sleep 1
143 + kill -0 "$QEMU_PID" 2>/dev/null || {
144 + cat "$SCRATCH/qemu-$1.log" >&2
145 + die "qemu died on '$1'"
146 + }
147 + }
148 +
149 + shutdown_vm() {
150 + [ -n "$QEMU_PID" ] || return 0
151 + qmp cmd quit >/dev/null 2>&1 || true
152 + # The socket goes away with the process, so `quit` returning nothing is the
153 + # ordinary path rather than a failure. Wait, then insist.
154 + for _ in $(seq 1 40); do
155 + kill -0 "$QEMU_PID" 2>/dev/null || { QEMU_PID=""; return 0; }
156 + sleep 0.25
157 + done
158 + kill -9 "$QEMU_PID" 2>/dev/null || true
159 + wait "$QEMU_PID" 2>/dev/null || true
160 + QEMU_PID=""
161 + }
162 +
163 + cleanup() {
164 + [ -n "$QEMU_PID" ] && kill -9 "$QEMU_PID" 2>/dev/null
165 + return 0
166 + }
167 + trap cleanup EXIT
168 +
169 + # ---- phase 1: install ----
170 +
171 + if [ "$KEEP_STATE" = 1 ]; then
172 + [ -f "$SCRATCH/target.qcow2" ] || die "--keep-state, but there is no state/target.qcow2 to keep"
173 + say "reusing the install in state/target.qcow2"
174 + else
175 + # Both together, always. Leaving the firmware variables behind boots the
176 + # half-written disk to a grub prompt; build/vmtest/README.md records the
177 + # afternoon that cost.
178 + say "resetting state/"
179 + rm -f "$SCRATCH/target.qcow2" "$SCRATCH/OVMF_VARS.fd"
180 + rm -rf "$SCRATCH/tpm"
181 +
182 + say "booting the installer medium"
183 + ALLOY_ISO="$ISO" boot live
184 +
185 + say "driving the wizard"
186 + rc=0
187 + python3 "$HERE/install_drive.py" \
188 + --key "$KEY" --port "$PORT" --disk "$DISK_NAME" \
189 + --hostname "$HOSTNAME_" --user "$USER_" --password "$PASSWORD" \
190 + --install-timeout "$INSTALL_TIMEOUT" || rc=$?
191 + shutdown_vm
192 + [ "$rc" -eq 0 ] || {
193 + [ "$rc" -eq 1 ] && { printf 'FAIL: the install itself failed, so the first boot was never reached\n' >&2; exit 1; }
194 + die "the wizard could not be driven to a verdict"
195 + }
196 + say "installed"
197 + fi
198 +
199 + # ---- phase 2: the offline first boot ----
200 +
201 + say "booting the installed machine with its link down"
202 + boot installed
203 + # Before the guest's firmware has handed over, let alone before userspace. The
204 + # NIC reports no carrier for the whole of the boot that matters.
205 + qmp cmd set_link '{"name": "nic0", "up": false}' >/dev/null
206 +
207 + say "waiting up to ${LAYER_TIMEOUT}s for the machine to lay its components down and reboot"
208 + REBOOTED=1
209 + # `guest: true` rather than any RESET: a reset the host asked for and a reboot
210 + # the guest performed are the same event with a different reason, and only the
211 + # second one is the unit's last act.
212 + qmp wait RESET "$LAYER_TIMEOUT" '{"guest": true}' >/dev/null 2>&1 || REBOOTED=0
213 + [ "$REBOOTED" = 1 ] && say "it rebooted"
214 +
215 + # ---- phase 3: read the result ----
216 +
217 + # Only now, and it cannot change the answer: the unit's second condition is
218 + # `ConditionPathExists=!/usr/bin/alloy`, so a machine that has a console does
219 + # not run it again, and one that does not have a console failed before this.
220 + say "putting the link back up"
221 + qmp cmd set_link '{"name": "nic0", "up": true}' >/dev/null
222 +
223 + say "waiting up to ${SSH_TIMEOUT}s for ssh"
224 + reachable=0
225 + end=$((SECONDS + SSH_TIMEOUT))
226 + while [ "$SECONDS" -lt "$end" ]; do
227 + if guest true >/dev/null 2>&1; then reachable=1; break; fi
228 + sleep 5
229 + done
230 + [ "$reachable" = 1 ] || {
231 + shutdown_vm
232 + die "the machine never answered ssh, so nothing can be read off it. \
233 + state/serial-installed.log and state/qemu-installed.log are what there is."
234 + }
235 +
236 + # 2. The console got laid down, read together with 1 because the pair is what
237 + # says which run this was. The unit's own conditions make "no reboot" mean two
238 + # different things, and reporting them the same way is how a scenario run
239 + # against the wrong disk gets filed as a regression.
240 + CONSOLE=1
241 + guest 'test -x /usr/bin/alloy' >/dev/null 2>&1 || CONSOLE=0
242 +
243 + if [ "$REBOOTED" = 0 ] && [ "$CONSOLE" = 1 ]; then
244 + # ConditionPathExists=!/usr/bin/alloy was already false, so the unit was a
245 + # no-op and nothing here was measured. Not a defect, and not a pass either.
246 + shutdown_vm
247 + die "this machine had already been first-booted: it has a console and never \
248 + ran the unit. Reset state/ and install again, or drop --keep-state."
249 + fi
250 +
251 + if [ "$REBOOTED" = 0 ]; then
252 + fail "the machine never rebooted, so alloy-layer-components.service did not finish"
253 + fi
254 + if [ "$CONSOLE" = 1 ]; then
255 + say "/usr/bin/alloy: present ($(guest '/usr/bin/alloy --version' 2>/dev/null | head -1))"
256 + else
257 + fail "/usr/bin/alloy is absent: the machine came up with no console"
258 + printf '\nwhat the unit said:\n' >&2
259 + guest 'journalctl -u alloy-layer-components.service --no-pager -o cat' 2>&1 | tail -40 >&2 || true
260 + fi
261 +
262 + # 3. The repo set. Read from both /etc and the image's own /usr/etc, because
263 + # `alloy-layer-repos on` restores one from the other and comparing them is the
264 + # assertion in its own terms. The count is asserted separately: deriving the
265 + # expectation from /usr/etc alone would follow a change to the image silently,
266 + # and "alloy-local plus Fedora's four" is a number somebody decided.
267 + # Fed on stdin rather than quoted into the ssh command line, so the awk program
268 + # is written once in its own syntax instead of through two layers of shell
269 + # quoting. A repo file can carry more than one section, so the id is tracked
270 + # rather than taken from the filename.
271 + enabled_repos() {
272 + guest 'sh -s' "$1" <<'AWKEOF' 2>/dev/null || true
273 + awk '
274 + /^\[/ { id = substr($0, 2, index($0, "]") - 2) }
275 + /^enabled[ \t]*=[ \t]*1[ \t]*$/ { if (id != "") print id }
276 + ' "$1"/*.repo | sort -u
277 + AWKEOF
278 + }
279 + LIVE_REPOS="$(enabled_repos /etc/yum.repos.d)"
280 + IMAGE_REPOS="$(enabled_repos /usr/etc/yum.repos.d)"
281 +
282 + if [ -z "$LIVE_REPOS" ]; then
283 + fail "no repo is enabled on the machine at all, so the fence was never lowered"
284 + elif [ "$LIVE_REPOS" != "$IMAGE_REPOS" ]; then
285 + fail "the enabled repo set does not match the image's own"
286 + printf ' /etc: %s\n' "$(echo "$LIVE_REPOS" | paste -sd' ')" >&2
287 + printf ' /usr/etc: %s\n' "$(echo "$IMAGE_REPOS" | paste -sd' ')" >&2
288 + else
289 + count="$(printf '%s\n' "$LIVE_REPOS" | wc -l)"
290 + echo "$LIVE_REPOS" | grep -qx 'alloy-local' \
291 + || fail "alloy-local is not enabled, so the carried repo is not reachable"
292 + [ "$count" -eq 5 ] \
293 + || fail "$count repos are enabled, not the five this expects (alloy-local plus Fedora's four): $(echo "$LIVE_REPOS" | paste -sd' ')"
294 + [ "$count" -eq 5 ] && say "repos: $(echo "$LIVE_REPOS" | paste -sd' ')"
295 + fi
296 +
297 + # 4. And the labels, folded in because this is the only place an install from
298 + # the wizard is standing still and reachable. Copied and then run rather than
299 + # fed on stdin: sudo wants the password there.
300 + say "running check-installed.sh on the guest"
301 + scp "${SSH_OPTS[@]}" "$REPO_ROOT/build/check-installed.sh" \
302 + "$USER_@127.0.0.1:/tmp/check-installed.sh" >/dev/null 2>&1 \
303 + || fail "could not copy check-installed.sh to the guest"
304 + ci=0
305 + guest "printf '%s\n' '$PASSWORD' | sudo -S -p '' bash /tmp/check-installed.sh" || ci=$?
306 + case "$ci" in
307 + 0) say "check-installed: clean" ;;
308 + 1) fail "check-installed found a defect on the installed machine (above)" ;;
309 + *) printf 'note: check-installed could not run (exit %s); not counted against the machine\n' "$ci" >&2 ;;
310 + esac
311 +
312 + shutdown_vm
313 +
314 + if [ "$FAILED" -ne 0 ]; then
315 + printf '\n%s check(s) failed. The machine did not survive an offline first boot.\n' "$FAILED" >&2
316 + exit 1
317 + fi
318 + printf '\nAn offline first boot laid the console down, left the ordinary repo set, and labelled /etc correctly.\n'
@@ -1,0 +1,179 @@
1 + #!/usr/bin/env python3
2 + """Talk to qemu's QMP socket: run a command, or wait for an event.
3 +
4 + `vm.py` drives the human monitor, which is the right interface for sendkey and
5 + screendump. This is the other one, and it exists for the thing HMP cannot do:
6 + say when something happened inside the guest.
7 +
8 + A scripted first-boot test has to know when the guest rebooted. HMP has no
9 + answer — `info status` reports running either side of a reset, and a script
10 + left watching the clock or the qcow2's size is guessing. QMP emits `RESET` as
11 + an event, so "the machine rebooted" becomes a thing to wait for rather than a
12 + thing to infer. When it does not arrive, the timeout is itself the finding.
13 +
14 + `set_link` is here for the same reason it is not in vm.py: taking the guest's
15 + route away from outside is the only way to do it that does not depend on the
16 + guest cooperating. Doing it from inside means `ip link set enp0s2 down` over
17 + the ssh session that command arrived on, which kills the session in the middle
18 + of the thing being measured — the hand run on 2026-08-25 needed a detached
19 + `setsid` script that brought the interface back up at the end, because there is
20 + no other way home. From out here the link is a property of the device.
21 +
22 + Use:
23 +
24 + qmp.py cmd query-status
25 + qmp.py cmd set_link '{"name": "n0", "up": false}'
26 + qmp.py wait RESET 900
27 + qmp.py wait RESET 900 '{"guest": true}'
28 + qmp.py cmd quit
29 +
30 + `wait` exits 0 when the event arrives and 1 when it does not, so a scenario
31 + script reads the verdict off the exit status.
32 + """
33 +
34 + import json
35 + import os
36 + import socket
37 + import sys
38 + import time
39 +
40 + SCRATCH = os.environ.get(
41 + "VM_STATE", os.path.join(os.path.dirname(os.path.abspath(__file__)), "state")
42 + )
43 + SOCK = os.path.join(SCRATCH, "qmp.sock")
44 +
45 +
46 + class QMP:
47 + def __init__(self, path=SOCK, connect_timeout=30.0):
48 + # qemu creates the socket as it starts, so a scenario that launches
49 + # qemu and connects immediately is racing it. Retrying here rather than
50 + # sleeping at every call site.
51 + end = time.time() + connect_timeout
52 + while True:
53 + try:
54 + self.s = socket.socket(socket.AF_UNIX)
55 + self.s.connect(path)
56 + break
57 + except (FileNotFoundError, ConnectionRefusedError):
58 + if time.time() >= end:
59 + raise
60 + time.sleep(0.2)
61 + # A raw buffer rather than socket.makefile. A buffered reader that
62 + # raises socket.timeout can leave a partial line in a buffer nobody
63 + # can get at again, which turns one slow read into a stream that never
64 + # parses. Owning the buffer keeps a timeout a timeout.
65 + self.buf = b""
66 + self.events = []
67 + self._read(timeout=30.0) # the greeting
68 + self.execute("qmp_capabilities")
69 +
70 + def _line(self, timeout):
71 + """One newline-terminated message from the socket."""
72 + while b"\n" not in self.buf:
73 + self.s.settimeout(timeout)
74 + chunk = self.s.recv(65536)
75 + if not chunk:
76 + raise EOFError("qemu closed the QMP socket")
77 + self.buf += chunk
78 + line, self.buf = self.buf.split(b"\n", 1)
79 + return line
80 +
81 + def _read(self, timeout=None):
82 + """One JSON reply, with events set aside rather than returned.
83 +
84 + Events interleave with replies, so a reader that returned the first
85 + message it saw would hand a caller someone else's RESET."""
86 + while True:
87 + msg = json.loads(self._line(timeout))
88 + if "event" in msg:
89 + self.events.append(msg)
90 + continue
91 + return msg
92 +
93 + def execute(self, command, arguments=None, timeout=30.0):
94 + payload = {"execute": command}
95 + if arguments:
96 + payload["arguments"] = arguments
97 + self.s.sendall((json.dumps(payload) + "\n").encode())
98 + # `quit` is answered and then the socket goes away, sometimes in that
99 + # order and sometimes not, so losing the reply to it is not a failure.
100 + try:
101 + reply = self._read(timeout)
102 + except EOFError:
103 + if command == "quit":
104 + return {}
105 + raise
106 + if "error" in reply:
107 + raise RuntimeError("%s: %s" % (command, reply["error"].get("desc", reply)))
108 + return reply.get("return", {})
109 +
110 + def wait_event(self, name, timeout, data=None):
111 + """The next `name` event, or None if the timeout runs out first.
112 +
113 + `data` narrows it to events whose payload carries those keys and
114 + values, which is how RESET becomes a question about the guest rather
115 + than about the machine: a reset the host asked for and a reboot the
116 + guest performed are the same event with a different `reason`, and only
117 + one of them is anything being measured.
118 +
119 + Events already buffered count: one that arrived while a command was in
120 + flight is the same event, and dropping it would make this depend on
121 + when it was called."""
122 + end = time.time() + timeout
123 +
124 + def matches(e):
125 + if e.get("event") != name:
126 + return False
127 + payload = e.get("data", {})
128 + return all(payload.get(k) == v for k, v in (data or {}).items())
129 +
130 + while True:
131 + for i, e in enumerate(self.events):
132 + if matches(e):
133 + return self.events.pop(i)
134 + remaining = end - time.time()
135 + if remaining <= 0:
136 + return None
137 + try:
138 + # Nothing but events is expected here, and _read files those
139 + # away itself; a reply arriving would be someone else's.
140 + self._read(min(remaining, 1.0))
141 + except socket.timeout:
142 + continue
143 + except EOFError:
144 + return None
145 +
146 + def close(self):
147 + try:
148 + self.s.close()
149 + except OSError:
150 + pass
151 +
152 +
153 + def main():
154 + if len(sys.argv) < 2:
155 + raise SystemExit(__doc__)
156 + verb = sys.argv[1]
157 + q = QMP()
158 + if verb == "cmd":
159 + command = sys.argv[2]
160 + arguments = json.loads(sys.argv[3]) if len(sys.argv) > 3 else None
161 + print(json.dumps(q.execute(command, arguments)))
162 + return
163 + if verb == "wait":
164 + name = sys.argv[2]
165 + seconds = float(sys.argv[3]) if len(sys.argv) > 3 else 300.0
166 + data = json.loads(sys.argv[4]) if len(sys.argv) > 4 else None
167 + event = q.wait_event(name, seconds, data)
168 + if event is None:
169 + print("no %s%s in %gs"
170 + % (name, " matching %s" % json.dumps(data) if data else "", seconds),
171 + file=sys.stderr)
172 + raise SystemExit(1)
173 + print(json.dumps(event))
174 + return
175 + raise SystemExit(__doc__)
176 +
177 +
178 + if __name__ == "__main__":
179 + main()
@@ -1,0 +1,405 @@
1 + #!/usr/bin/env python3
2 + """Read a full-screen TUI over a pty, and type at it.
3 +
4 + `ssh_pty.py` captures the bytes a TUI draws. This reads the SCREEN, and the
5 + difference is the whole reason a scripted wizard walk is possible at all.
6 +
7 + ratatui redraws only the cells that changed. "step 1 of 6" becoming
8 + "step 2 of 6" puts a single `2` on the wire behind a cursor move, so a script
9 + watching the byte stream for the new title sees it exactly once — on the first
10 + frame, where everything is written — and then silently never again. Every
11 + subsequent screen is a diff against a screen the script never reconstructed.
12 + Grepping the stream is not a weaker version of reading the screen; it is a
13 + different thing that happens to work on frame one.
14 +
15 + So this keeps a grid and applies the escapes to it. `Screen.text()` is what is
16 + on the screen right now, which is a question with an answer, and
17 + `Session.wait_for` is "wait until the screen says this".
18 +
19 + The escape subset is crossterm's output and nothing more: absolute and relative
20 + cursor moves, the two erases, and printable text. SGR, the alternate-screen
21 + toggles, OSC and DCS are parsed only far enough to be skipped, because they
22 + carry nothing a script reads. A sequence this does not know is skipped rather
23 + than printed, which is the failure that would otherwise fill the grid with
24 + `[38;5;` and look like the application misbehaving.
25 +
26 + Not a terminal emulator. No scrollback, no autowrap, no character sets, no
27 + line-drawing translation. A full-screen TUI positions every run it draws, so
28 + none of that is reachable from here; if something starts depending on it, the
29 + honest fix is to say so rather than to grow this file toward xterm.
30 +
31 + Use:
32 +
33 + from tui import Session
34 + s = Session(["ssh", ...])
35 + s.wait_for(r"step 1 of 6", 60)
36 + s.send("alloytest\r")
37 + print(s.screen.text())
38 + """
39 +
40 + import codecs
41 + import fcntl
42 + import os
43 + import pty
44 + import re
45 + import select
46 + import signal
47 + import struct
48 + import sys
49 + import termios
50 + import time
51 +
52 + ROWS, COLS = 40, 120
53 +
54 +
55 + class Timeout(RuntimeError):
56 + """What the screen said when the wait ran out, so a failure is readable."""
57 +
58 + def __init__(self, pattern, seconds, screen):
59 + super().__init__(
60 + "waited %gs for %r; the screen said:\n\n%s\n" % (seconds, pattern, screen)
61 + )
62 + self.screen = screen
63 +
64 +
65 + class Screen:
66 + """A character grid, and the escapes needed to keep it current."""
67 +
68 + def __init__(self, rows=ROWS, cols=COLS):
69 + self.rows, self.cols = rows, cols
70 + self.grid = [[" "] * cols for _ in range(rows)]
71 + self.row = self.col = 0
72 + # Incremental, because a read can split a multi-byte character and a
73 + # per-chunk decode would put a replacement character in the grid.
74 + self._decode = codecs.getincrementaldecoder("utf-8")("replace")
75 + # Holds a partial escape across reads for the same reason.
76 + self._buf = ""
77 +
78 + def text(self):
79 + """The screen as lines, right-trimmed. Rows are padded, and trailing
80 + blanks on every line would make every pattern need to allow for them."""
81 + return "\n".join("".join(row).rstrip() for row in self.grid)
82 +
83 + def feed(self, data):
84 + self._buf += self._decode.decode(data)
85 + s, i, n = self._buf, 0, len(self._buf)
86 + while i < n:
87 + ch = s[i]
88 + if ch == "\x1b":
89 + nxt = self._escape(s, i)
90 + if nxt is None:
91 + break # incomplete; the rest of it is in the next read
92 + i = nxt
93 + continue
94 + i += 1
95 + if ch == "\r":
96 + self.col = 0
97 + elif ch == "\n":
98 + self._newline()
99 + elif ch == "\b":
100 + self.col = max(0, self.col - 1)
101 + elif ch == "\t":
102 + self.col = min(self.cols - 1, (self.col // 8 + 1) * 8)
103 + elif ch >= " ":
104 + self._put(ch)
105 + # Everything else is a control character a TUI does not use.
106 + self._buf = s[i:]
107 +
108 + # ---- the grid ----
109 +
110 + def _put(self, ch):
111 + self.grid[self.row][self.col] = ch
112 + # Clamped rather than wrapped. A TUI positions every run it draws, so
113 + # the right margin is never reached by accident, and wrapping would
114 + # scroll the screen out from under a script on a full-width line.
115 + self.col = min(self.col + 1, self.cols - 1)
116 +
117 + def _newline(self):
118 + if self.row + 1 < self.rows:
119 + self.row += 1
120 +
121 + def _clamp(self, row, col):
122 + self.row = max(0, min(row, self.rows - 1))
123 + self.col = max(0, min(col, self.cols - 1))
124 +
125 + def _blank(self, row, start, end):
126 + for c in range(max(0, start), min(end, self.cols)):
127 + self.grid[row][c] = " "
128 +
129 + # ---- escapes ----
130 +
131 + def _escape(self, s, i):
132 + """Index just past the sequence starting at `i`, or None if it is not
133 + all here yet."""
134 + if i + 1 >= len(s):
135 + return None
136 + kind = s[i + 1]
137 + if kind == "[":
138 + j = i + 2
139 + while j < len(s) and "\x30" <= s[j] <= "\x3f":
140 + j += 1
141 + while j < len(s) and "\x20" <= s[j] <= "\x2f":
142 + j += 1
143 + if j >= len(s):
144 + return None
145 + self._csi(s[i + 2 : j], s[j])
146 + return j + 1
147 + if kind in "]P^_":
148 + # OSC, DCS, PM, APC: a string terminated by BEL or by ST.
149 + j = i + 2
150 + while j < len(s):
151 + if s[j] == "\x07":
152 + return j + 1
153 + if s[j] == "\x1b":
154 + if j + 1 >= len(s):
155 + return None
156 + if s[j + 1] == "\\":
157 + return j + 2
158 + j += 1
159 + return None
160 + if "\x20" <= kind <= "\x2f":
161 + # A two-character intermediate, such as a character-set select.
162 + return None if i + 2 >= len(s) else i + 3
163 + return i + 2
164 +
165 + def _csi(self, params, final):
166 + # `?` and friends mark private modes: cursor visibility, the alternate
167 + # screen, bracketed paste. None of them move the cursor or erase, so
168 + # skipping them is not an approximation.
169 + if params[:1] in ("?", "<", ">", "="):
170 + return
171 + nums = [int(p) if p.isdigit() else 0 for p in params.split(";")] if params else []
172 +
173 + def arg(k, default=1):
174 + # Zero means "the default" in every one of these, per ECMA-48.
175 + return nums[k] if k < len(nums) and nums[k] else default
176 +
177 + if final in "Hf":
178 + self._clamp(arg(0) - 1, arg(1) - 1)
179 + elif final == "A":
180 + self._clamp(self.row - arg(0), self.col)
181 + elif final == "B":
182 + self._clamp(self.row + arg(0), self.col)
183 + elif final == "C":
184 + self._clamp(self.row, self.col + arg(0))
185 + elif final == "D":
186 + self._clamp(self.row, self.col - arg(0))
187 + elif final == "G":
188 + self._clamp(self.row, arg(0) - 1)
189 + elif final == "d":
190 + self._clamp(arg(0) - 1, self.col)
191 + elif final == "J":
192 + # Mode 0 is the default here, so `arg` is wrong for J and K.
193 + mode = nums[0] if nums else 0
194 + if mode == 0:
195 + self._blank(self.row, self.col, self.cols)
196 + for r in range(self.row + 1, self.rows):
197 + self._blank(r, 0, self.cols)
198 + elif mode == 1:
199 + for r in range(0, self.row):
200 + self._blank(r, 0, self.cols)
201 + self._blank(self.row, 0, self.col + 1)
202 + else:
203 + for r in range(self.rows):
204 + self._blank(r, 0, self.cols)
205 + elif final == "K":
206 + mode = nums[0] if nums else 0
207 + if mode == 0:
208 + self._blank(self.row, self.col, self.cols)
209 + elif mode == 1:
210 + self._blank(self.row, 0, self.col + 1)
211 + else:
212 + self._blank(self.row, 0, self.cols)
213 + # SGR and everything else changes how the screen looks, not what it says.
214 +
215 +
216 + class Session:
217 + """A child under a pty of a known size, and the screen it is drawing."""
218 +
219 + def __init__(self, argv, rows=ROWS, cols=COLS, log_path=None):
220 + self.screen = Screen(rows, cols)
221 + self.argv = argv
222 + self.log = open(log_path, "wb") if log_path else None
223 + self.pid, self.fd = pty.fork()
224 + if self.pid == 0:
225 + os.execvp(argv[0], argv)
226 + os._exit(127)
227 + fcntl.ioctl(self.fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
228 + self.eof = False
229 +
230 + def pump(self, seconds=0.2):
231 + """Read for up to `seconds`, applying whatever arrives to the screen."""
232 + end = time.time() + seconds
233 + while time.time() < end:
234 + r, _, _ = select.select([self.fd], [], [], max(0.0, end - time.time()))
235 + if not r:
236 + continue
237 + try:
238 + chunk = os.read(self.fd, 65536)
239 + except OSError:
240 + self.eof = True
241 + return
242 + if not chunk:
243 + self.eof = True
244 + return
245 + self.screen.feed(chunk)
246 + if self.log:
247 + self.log.write(chunk)
248 + self.log.flush()
249 +
250 + def wait_for(self, pattern, seconds, poll=0.2):
251 + """Block until the screen matches, and return the match.
252 +
253 + A regex over the whole screen rather than over the stream: see the
254 + module docstring for why the stream cannot answer this."""
255 + end = time.time() + seconds
256 + rx = re.compile(pattern)
257 + while True:
258 + hit = rx.search(self.screen.text())
259 + if hit:
260 + return hit
261 + if time.time() >= end:
262 + raise Timeout(pattern, seconds, self.screen.text())
263 + if self.eof:
264 + # One more look: the last frame before the child left may
265 + # carry the thing being waited for.
266 + if rx.search(self.screen.text()):
267 + return rx.search(self.screen.text())
268 + raise Timeout(pattern, seconds, self.screen.text() + "\n\n(the session ended)")
269 + self.pump(poll)
270 +
271 + def send(self, text, settle=0.4):
272 + """Type, then let the frame that answers arrive before anyone reads."""
273 + os.write(self.fd, text.encode())
274 + self.pump(settle)
275 +
276 + def close(self):
277 + try:
278 + os.kill(self.pid, signal.SIGKILL)
279 + except ProcessLookupError:
280 + pass
281 + try:
282 + os.waitpid(self.pid, 0)
283 + except ChildProcessError:
284 + pass
285 + if self.log:
286 + self.log.close()
287 +
288 +
289 + def self_test():
290 + """Check the parser, because a wrong parse still produces a screen.
291 +
292 + Same reason check-installed.sh and check-rust-stage.sh carry one: the part
293 + that fails invisibly is the part that turns input into a verdict, and a
294 + verdict is what this file produces. Every case below is a shape crossterm
295 + actually emits.
296 + """
297 + fails = []
298 +
299 + def check(label, got, want):
300 + if got != want:
301 + fails.append("%s\n got: %r\n want: %r" % (label, got, want))
302 +
303 + # The case this file exists for, and the one a stream search cannot answer:
304 + # a redraw that rewrites one cell. Search the stream for "step 2 of 6" and
305 + # it is not there; read the screen and it is.
306 + s = Screen(3, 20)
307 + s.feed(b"\x1b[2J\x1b[1;1Hstep 1 of 6\x1b[1;6H2")
308 + check("a one-cell redraw", s.text().splitlines()[0], "step 2 of 6")
309 +
310 + # SGR carries how the screen looks, not what it says, and must leave
311 + # nothing behind. This is the failure that fills a grid with `[38;5;`.
312 + s = Screen(1, 30)
313 + s.feed(b"\x1b[1;38;5;203mred\x1b[0m ok")
314 + check("SGR is skipped", s.text(), "red ok")
315 +
316 + # Erase to end of line, which is how a footer's hints are replaced when a
317 + # step changes the shorter set for the longer one.
318 + s = Screen(1, 20)
319 + s.feed(b"\x1b[1;1Hesc back\x1b[1;1Hr reboot\x1b[K")
320 + check("erase to end of line", s.text(), "r reboot")
321 +
322 + # And erase-all, which is the first thing a full repaint does.
323 + s = Screen(2, 10)
324 + s.feed(b"\x1b[1;1Hgone\x1b[2;1Halso\x1b[2J\x1b[1;1Hnew")
325 + check("erase display", s.text(), "new\n")
326 +
327 + # An escape split across two reads. A parser that consumed the partial
328 + # sequence would print `[2;` into the grid and lose the move.
329 + s = Screen(2, 10)
330 + s.feed(b"a\x1b[2")
331 + s.feed(b";1Hb")
332 + check("a split escape", s.text(), "a\nb")
333 +
334 + # A UTF-8 character split across two reads. The list marker is three bytes,
335 + # and a per-chunk decode would put a replacement character in the gutter
336 + # and make every row read as unselected.
337 + s = Screen(1, 10)
338 + s.feed(b"\xe2\x96")
339 + s.feed(b"\xb6 vda")
340 + check("a split character", s.text(), "\u25b6 vda")
341 +
342 + # OSC, which crossterm emits to set the window title. Terminated by BEL
343 + # here and by ST in the next case; a parser that knew only one would print
344 + # the other's payload.
345 + s = Screen(1, 20)
346 + s.feed(b"\x1b]0;alloy\x07ok")
347 + check("OSC ended by BEL", s.text(), "ok")
348 + s = Screen(1, 20)
349 + s.feed(b"\x1b]0;alloy\x1b\\ok")
350 + check("OSC ended by ST", s.text(), "ok")
351 +
352 + # Relative moves and the column/row absolutes.
353 + s = Screen(2, 10)
354 + s.feed(b"\x1b[1;1Habc\x1b[1D\x1b[1BX\x1b[1;1H\x1b[5GY")
355 + # `abc` leaves the cursor at column 3; one left is 2, one down writes the X
356 + # there, and `5G` is column 4 counting from one.
357 + check("relative moves", s.text(), "abc Y\n X")
358 +
359 + # A pane the way one really arrives: a border, a gutter marker, and rows.
360 + # The assertion is the question install_drive.py asks of the disk step.
361 + s = Screen(4, 40)
362 + s.feed(
363 + b"\x1b[2J"
364 + b"\x1b[1;1H\xe2\x94\x8c\x1b[1;2H select a disk"
365 + b"\x1b[2;1H\xe2\x94\x82\x1b[2;3H vda 40.0 GiB"
366 + b"\x1b[3;1H\xe2\x94\x82\x1b[3;3H sdb 931.5 GiB"
367 + # ...and then the selection moves down, which rewrites two gutters.
368 + b"\x1b[3;3H\xe2\x96\xb6"
369 + )
370 + row = re.search("\u25b6" + r"\s+(\S+)", s.text())
371 + check("the selected row is readable", row and row.group(1), "sdb")
372 +
373 + if fails:
374 + for f in fails:
375 + print("self-test: " + f, file=sys.stderr)
376 + print("self-test: %d failed" % len(fails), file=sys.stderr)
377 + return 1
378 + print("self-test: the parser is right about all ten shapes")
379 + return 0
380 +
381 +
382 + def main():
383 + """Ad-hoc use: run a command, wait for a pattern, print the screen."""
384 + if len(sys.argv) > 1 and sys.argv[1] == "--self-test":
385 + raise SystemExit(self_test())
386 + if len(sys.argv) < 3:
387 + raise SystemExit("usage: tui.py <seconds> <pattern> <command> [args...]\n"
388 + " an empty pattern just reads for <seconds>")
389 + seconds, pattern, argv = float(sys.argv[1]), sys.argv[2], sys.argv[3:]
390 + s = Session(argv)
391 + try:
392 + if pattern:
393 + s.wait_for(pattern, seconds)
394 + else:
395 + s.pump(seconds)
396 + print(s.screen.text())
397 + except Timeout as e:
398 + print(e, file=sys.stderr)
399 + raise SystemExit(1)
400 + finally:
401 + s.close()
402 +
403 +
404 + if __name__ == "__main__":
405 + main()