Skip to main content

max / alloy

Drive the offline first-boot vmtest from the serial root shell Running the test found three things the written-but-unrun version could not, and the first of them changes how it works. THE SSH INSTALLER CANNOT INSTALL. The medium's headless flow was this test's way in: alloy-installer-ssh.service creates `installer` on the live medium and the sshd drop-in makes that session BE `alloy install`. The wizard draws and answers correctly all six steps and the erase confirmation, and then every privileged stage fails, because `alloy install` never escalates and that account is a plain `useradd --system` with no grant anywhere: error: Installing to disk: Querying root privilege: This command must be executed as the root user wipefs: error: /dev/vda: probing initialization failed: Permission denied So the driver takes GRUB's debug entry instead, which carries `alloy.debug` and starts alloy-debug-shell@ttyS0: a root bash on the serial console. `--via ssh` stays for the day GO alloy 2cf04f20 is answered. GRUB is now navigated by reading. Its menu is on the serial console, and the mark says which row is current, so the harness holds the countdown open with a keypress, moves until the mark is where it should be, and only then presses return. That is both of the README's countdown warnings answered rather than worked around. The first attempt walked to the bottom of the menu and stopped: `line.strip()` leaves GRUB's border in front of the mark, so `startswith("*")` was false on every row. THE INSTALLED MACHINE'S LOGIN SHELL IS NUSHELL, so a command handed to ssh is not a shell command. `2>&1` is a parse error there, `cat > file` reads the `>` as an argument, and scp fails outright because it runs its far end through the login shell too. Two of those three read as the machine being broken. Every remote command goes through `sh -s` with the script on stdin now, which is what the repo assertion already did and why it was the one that worked. And a failure to DELIVER check-installed.sh was being counted against the machine. It is a run problem: exit 3 saying the first boot passed but the labels are unchecked, rather than exit 1 saying the machine did not survive. tui.py grows SocketSession for the serial transport, sharing the screen model with the pty one. Measured on fw13 against a server-profile ISO built from this tree. The four assertions pass on a real machine: the guest rebooted itself, /usr/bin/alloy is present (alloy 0.1.0), the enabled set is `alloy-local fedora fedora-cisco-openh264 updates updates-archive`, and check-installed reports clean labels and a DynamicUser unit that starts. GO alloy cd2da818.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 16:54 UTC
Signed with PGP, not checked
Commit: 4232bd9c5172f74c901fc3e75417de563f3cb86c
Parent: 1c2515e
4 files changed, +312 insertions, -48 deletions
@@ -89,6 +89,21 @@
89 89 **The recovery phrase is legible in a screendump.** This was believed to need a
90 90 person, which is why the encrypted path went untested for so long. It does not.
91 91
92 + **The installed machine's login shell is nushell, so a remote command is not a
93 + shell command.** ssh hands what you give it to the login shell, and that is
94 + `/usr/bin/nu`. `... 2>&1` is a parse error there (`use 'out+err>'`), `cat >
95 + file` reads the `>` as an argument, and `scp` fails outright because it runs
96 + its far end through the login shell too. Two of those three read as the machine
97 + being broken rather than as the command being wrong. Route everything through a
98 + POSIX shell explicitly, and prefer `ssh <host> 'sh -s' < script`: the script is
99 + then written once in its own syntax with nothing to quote through nushell. Use
100 + `sh -c '...'` only when stdin is carrying something else.
101 +
102 + **The wizard cannot be driven over the medium's ssh installer**, because that
103 + session is unprivileged and every stage of the install fails. It is the obvious
104 + way in and it is the wrong one until GO alloy `2cf04f20` is answered; the root
105 + shell on GRUB's debug entry is what works. See `install_drive.py`.
106 +
92 107 ## Check the result, not only the screen
93 108
94 109 An install that finishes is not an install that worked. `bootc container lint`
@@ -163,13 +178,22 @@
163 178 fails, so it never reaches its `systemctl reboot`, so no `RESET` arrives and
164 179 `/usr/bin/alloy` is absent afterwards.
165 180
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:
181 + The installer is driven from the root shell GRUB's debug entry starts
182 + (`alloy-debug-shell@ttyS0`), not over the medium's ssh installer account: that
183 + account is unprivileged and cannot install anything (GO alloy `2cf04f20`).
184 + `install_drive.py --via ssh` keeps that route for the day it is fixed.
169 185
170 - build/build-iso.sh --build-arg ALLOY_SSH_KEY="$(cat ~/.ssh/id_ed25519.pub)"
186 + build/build-iso.sh --build-arg PROFILE=server --build-arg BROWSER=none
171 187 build/vmtest/offline-first-boot.sh
172 188
189 + The key it is given is not a credential for the medium; it is what the wizard
190 + puts in the new machine's `authorized_keys`, which is how the assertions get in
191 + afterwards.
192 +
193 + GRUB is navigated by reading the menu off the serial console and checking the
194 + mark before pressing return, so neither of the countdown warnings above applies
195 + to this script.
196 +
173 197 Four things are asserted, and the last is folded in rather than separate
174 198 because a scenario that boots a fresh machine is already holding everything
175 199 `check-installed.sh` needs:
@@ -1,18 +1,37 @@
1 1 #!/usr/bin/env python3
2 - """Walk `alloy install` to the end, over the medium's own ssh installer.
2 + """Walk `alloy install` to the end, in a VM, without a person at the keyboard.
3 3
4 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.
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 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.
12 + ## Two ways in, and the default is not the obvious one
13 +
14 + `--via serial` (the default) boots GRUB's debug entry, which carries
15 + `alloy.debug` and so starts `alloy-debug-shell@ttyS0`: a root bash on the
16 + guest's serial console. `alloy install` is run from there.
17 +
18 + `--via ssh` uses the medium's own headless flow instead:
19 + `alloy-installer-ssh.service` creates the `installer` account on the live
20 + medium only, and the sshd drop-in makes that account's session BE
21 + `alloy install` under a real tty. It is the nicer route and it is not the
22 + default, because **it cannot currently install anything**. Measured
23 + 2026-08-26: the wizard draws and answers correctly all the way to the erase
24 + confirmation, and then every privileged stage fails.
25 +
26 + error: Installing to disk: Querying root privilege: This command must be
27 + executed as the root user
28 + mkdir: cannot create directory '/run/alloy-target': Permission denied
29 + wipefs: error: /dev/vda: probing initialization failed: Permission denied
30 +
31 + `alloy install` never escalates: on tty1 it is already root, because
32 + alloy-installer.service runs it as root. Over ssh it runs as `installer`, a
33 + `useradd --system` account, and nothing in the tree or the image grants that
34 + account anything. The flag is kept so the day that is fixed, this is one word.
16 35
17 36 The answers are the ones a regression test wants and not the ones a person
18 37 would pick:
@@ -21,12 +40,12 @@
21 40 nothing downstream of here touches LUKS. `--encrypt` turns
22 41 it back on for a run that means to exercise it.
23 42 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.
43 + nothing can be asserted about it.
26 44
27 45 Use:
28 46
29 - install_drive.py --key ~/.ssh/id_ed25519 --user tester --hostname alloytest
47 + install_drive.py --pubkey "$(cat ~/.ssh/id_ed25519.pub)"
48 + install_drive.py --via ssh --key ~/.ssh/id_ed25519
30 49
31 50 Exits 0 when the install reports success, 1 when it reports failure, and 3 when
32 51 the run could not get far enough to have a verdict. The screen is printed on
@@ -41,7 +60,8 @@
41 60 import time
42 61
43 62 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
44 - from tui import Session, Timeout # noqa: E402
63 + from tui import Session, SocketSession, Timeout # noqa: E402
64 + from vm import Monitor # noqa: E402
45 65
46 66 SCRATCH = os.environ.get(
47 67 "VM_STATE", os.path.join(os.path.dirname(os.path.abspath(__file__)), "state")
@@ -66,7 +86,7 @@
66 86 ]
67 87
68 88
69 - def connect(key, port, timeout):
89 + def connect_ssh(key, port, timeout):
70 90 """Keep trying until the medium's sshd is up, or give up saying so.
71 91
72 92 A live ISO takes tens of seconds to reach multi-user, and the account this
@@ -87,15 +107,124 @@
87 107 time.sleep(3)
88 108 raise SystemExit(
89 109 "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 "
110 + "The medium has to carry a baked pubkey matching the key given here -- "
111 + "build it with `--build-arg ALLOY_SSH_KEY=\"$(cat <key>.pub)\"` -- and "
92 112 "alloy-installer-ssh.service only creates the account when the kernel "
93 113 "command line carries `alloy.installer`, which only the ISO's GRUB "
94 114 "entries set.\nThe last thing the session showed:\n\n%s\n" % (port, last)
95 115 )
96 116
97 117
98 - MARKER = "\u25b6" # alloy_tui::selection::MARKER, the selected-row gutter mark.
118 + # One GRUB menu row: the box border, then the mark, then the title. Keyed on
119 + # the titles this ISO writes rather than on a row number, and the mark is
120 + # matched AFTER the border on purpose. `line.strip()` is not enough: it leaves
121 + # the `\u2502` in front, so `startswith("*")` is false on the selected row and a
122 + # navigator built on it walks to the bottom of the menu and stays there.
123 + GRUB_ROW = re.compile(r"^[^\w*]*(\*?)\s*((?:Install Alloy|Initramfs shell).*?)\s*$")
124 +
125 +
126 + def grub_menu(s):
127 + """The menu as `(selected, title)` pairs, or an empty list if it is not up."""
128 + rows = []
129 + for line in s.screen.text().splitlines():
130 + hit = GRUB_ROW.match(line)
131 + if hit:
132 + rows.append((hit.group(1) == "*", hit.group(2)))
133 + return rows
134 +
135 +
136 + def grub_select(s, entry, timeout=120.0):
137 + """Stop GRUB's countdown, move to the entry whose title contains `entry`,
138 + and boot it.
139 +
140 + GRUB draws its menu on the serial console (`make-iso.sh` puts
141 + `console=ttyS0` on every entry) and marks the current row. So which entry
142 + is selected is readable, and this navigates by reading rather than by
143 + counting keystrokes from an assumed starting row.
144 +
145 + That is the fix for two of the things build/vmtest/README.md records as
146 + costing an afternoon: the countdown expiring before the harness is ready,
147 + and pressing return without knowing what is highlighted. Any keypress stops
148 + the countdown, so the loop below holds the menu open by pressing one, and
149 + nothing is committed until the mark is where it should be.
150 + """
151 + monitor = Monitor()
152 + end = time.time() + timeout
153 + # Down-then-up is a pair that leaves the selection where it found it, which
154 + # is what makes holding the menu open safe to do before it has been read.
155 + while not grub_menu(s):
156 + if time.time() >= end:
157 + raise SystemExit("error: GRUB's menu never appeared on the serial console.\n\n%s\n"
158 + % s.screen.text())
159 + monitor.sendkey("down")
160 + monitor.sendkey("up")
161 + s.pump(0.3)
162 +
163 + titles = [title for _, title in grub_menu(s)]
164 + want = next((i for i, title in enumerate(titles) if entry in title), None)
165 + if want is None:
166 + raise SystemExit("error: no GRUB entry matching %r. The menu reads:\n %s\n"
167 + % (entry, "\n ".join(titles)))
168 +
169 + for _ in range(len(titles) * 3 + 6):
170 + marks = [i for i, (sel, _) in enumerate(grub_menu(s)) if sel]
171 + if marks == [want]:
172 + break
173 + if not marks:
174 + # The mark is repainted a moment after the move; wait for it rather
175 + # than pressing again, which is how a navigator overshoots.
176 + s.pump(0.4)
177 + continue
178 + monitor.sendkey("down" if marks[0] < want else "up")
179 + s.pump(0.4)
180 + else:
181 + raise SystemExit("error: could not put GRUB's mark on %r.\n\n%s\n"
182 + % (entry, s.screen.text()))
183 +
184 + monitor.sendkey("ret")
185 + s.pump(1.0)
186 +
187 +
188 + # Split so the echoed command line does not contain the string being waited
189 + # for. Without that, the wait is satisfied by the terminal echoing the command
190 + # back before the shell has run it, and the next thing typed lands in a shell
191 + # that is not ready.
192 + READY = "VMTEST-SHELL-UP"
193 + READY_CMD = 'echo VMTEST-SHELL"-"UP\n'
194 +
195 +
196 + def connect_serial(rows, cols, timeout):
197 + """Boot the debug entry, take its root shell, and start the installer in it.
198 +
199 + The debug entry rather than the default one, and root rather than the ssh
200 + account, because the ssh account cannot install: see the module docstring.
201 + `alloy-debug-shell@ttyS0` is gated on `alloy.debug`, which only these GRUB
202 + entries set, so this reaches nothing on an installed machine."""
203 + s = SocketSession(os.path.join(SCRATCH, "serial.sock"), rows=rows, cols=cols,
204 + log_path=os.path.join(SCRATCH, "install-drive.raw"))
205 + grub_select(s, "root shell on tty9")
206 +
207 + # The shell arrives some tens of seconds into the boot, and prompts differ,
208 + # so this asks rather than matching a prompt.
209 + end = time.time() + timeout
210 + while True:
211 + s.send("\n" + READY_CMD, settle=1.0)
212 + if READY in s.screen.text():
213 + break
214 + if time.time() >= end:
215 + raise SystemExit("error: no root shell on the serial console.\n\n%s\n"
216 + % s.screen.text())
217 +
218 + # The size is the guest's, not an ioctl from this end: a serial console has
219 + # no window size to inherit and defaults to 80x24, which is narrower than
220 + # the installer's own 80-column budget leaves room for.
221 + s.send("stty rows %d cols %d; export TERM=xterm-256color\n" % (rows, cols), settle=1.0)
222 + s.send("clear; alloy install\n", settle=2.0)
223 + s.wait_for(r"step 1 of 6", 60)
224 + return s
225 +
226 +
227 + MARKER = MARKER = "\u25b6" # alloy_tui::selection::MARKER, the selected-row gutter mark.
99 228
100 229
101 230 def selected_row(s):
@@ -207,8 +336,14 @@
207 336
208 337 def main():
209 338 p = argparse.ArgumentParser(description="drive `alloy install` to the end")
339 + p.add_argument("--via", choices=("serial", "ssh"), default="serial",
340 + help="serial: GRUB's debug entry and its root shell (the default, "
341 + "because the ssh route cannot install). ssh: the medium's own "
342 + "headless installer account")
343 + p.add_argument("--rows", type=int, default=40)
344 + p.add_argument("--cols", type=int, default=120)
210 345 p.add_argument("--key", default=os.path.expanduser("~/.ssh/id_ed25519"),
211 - help="private key matching the pubkey baked into the medium")
346 + help="--via ssh: private key matching the pubkey baked into the medium")
212 347 p.add_argument("--pubkey", default=None,
213 348 help="public key for the account being created (default: --key + .pub)")
214 349 p.add_argument("--port", type=int, default=2222)
@@ -229,7 +364,10 @@
229 364 raise SystemExit("error: no %s; pass --pubkey" % path)
230 365 args.pubkey = open(path).read().strip()
231 366
232 - s = connect(args.key, args.port, args.connect_timeout)
367 + if args.via == "ssh":
368 + s = connect_ssh(args.key, args.port, args.connect_timeout)
369 + else:
370 + s = connect_serial(args.rows, args.cols, args.connect_timeout)
233 371 try:
234 372 drive(s, args)
235 373 print("==> installing; this is the long part", flush=True)
@@ -50,19 +50,26 @@
50 50 # up is how the assertions get in, and on the failing path it is how the
51 51 # evidence gets out.
52 52 #
53 + # ## How the installer is driven
54 + #
55 + # Over the serial console, from the root shell GRUB's debug entry starts
56 + # (`alloy-debug-shell@ttyS0`, gated on `alloy.debug`). NOT over the medium's
57 + # own headless ssh installer, which would be the nicer route and cannot
58 + # install: that session runs as the unprivileged `installer` account and every
59 + # privileged stage fails. Measured 2026-08-26; install_drive.py's header
60 + # carries the errors, and the GoingsOn task is alloy `cd2da818`.
61 + #
53 62 # ## Requirements
54 63 #
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)"
64 + # Everything build/vmtest/README.md lists, and an ISO. No baked key is needed:
65 + # the key below is what the wizard puts in the NEW machine's authorized_keys,
66 + # which is how the assertions get in afterwards.
60 67 #
61 68 # ## Use
62 69 #
63 70 # build/vmtest/offline-first-boot.sh
64 71 # build/vmtest/offline-first-boot.sh --key ~/.ssh/alloy_vm
65 - # build/vmtest/offline-first-boot.sh --keep-state # reuse a finished install
72 + # build/vmtest/offline-first-boot.sh --keep-state # reuse an un-booted install
66 73 #
67 74 # Exit codes follow check-installed.sh and check-rust-stage.sh:
68 75 #
@@ -120,7 +127,7 @@
120 127
121 128 # ---- preflight ----
122 129
123 - for tool in qemu-system-x86_64 swtpm swtpm_setup python3 ssh scp; do
130 + for tool in qemu-system-x86_64 swtpm swtpm_setup python3 ssh; do
124 131 command -v "$tool" >/dev/null 2>&1 || die "no $tool"
125 132 done
126 133 [ -f /usr/share/OVMF/OVMF_CODE_4M.fd ] || die "no OVMF at /usr/share/OVMF"
@@ -133,7 +140,21 @@
133 140 -p "$PORT" -i "$KEY")
134 141
135 142 qmp() { python3 "$HERE/qmp.py" "$@"; }
136 - guest() { ssh "${SSH_OPTS[@]}" "$USER_@127.0.0.1" "$@"; }
143 +
144 + # THE INSTALLED MACHINE'S LOGIN SHELL IS NUSHELL (`/usr/bin/nu`), so a command
145 + # handed to ssh is parsed by nushell and not by sh. This is not a detail: the
146 + # obvious spellings fail, and two of them fail in ways that read as the machine
147 + # being broken rather than as the command being wrong. Measured 2026-08-26:
148 + #
149 + # `... 2>&1` -> nu::parser::shell_outerr, "use 'out+err>'"
150 + # `cat > /tmp/file` -> cat: '>': No such file or directory
151 + # `scp file host:/tmp/` -> fails, because scp runs its far end through the
152 + # login shell too
153 + #
154 + # So every remote command goes through a POSIX shell explicitly. `sh -s` with
155 + # the script on stdin is the form to reach for: the script is then written once
156 + # in its own syntax, with nothing to quote through nushell.
157 + guest() { ssh "${SSH_OPTS[@]}" "$USER_@127.0.0.1" 'sh -s' "$@"; }
137 158
138 159 boot() {
139 160 # run-vm.sh exec's qemu, so this pid is qemu's and killing it is enough.
@@ -185,7 +206,7 @@
185 206 say "driving the wizard"
186 207 rc=0
187 208 python3 "$HERE/install_drive.py" \
188 - --key "$KEY" --port "$PORT" --disk "$DISK_NAME" \
209 + --via serial --pubkey "$(cat "$KEY.pub")" --disk "$DISK_NAME" \
189 210 --hostname "$HOSTNAME_" --user "$USER_" --password "$PASSWORD" \
190 211 --install-timeout "$INSTALL_TIMEOUT" || rc=$?
191 212 shutdown_vm
@@ -224,7 +245,7 @@
224 245 reachable=0
225 246 end=$((SECONDS + SSH_TIMEOUT))
226 247 while [ "$SECONDS" -lt "$end" ]; do
227 - if guest true >/dev/null 2>&1; then reachable=1; break; fi
248 + if echo true | guest >/dev/null 2>&1; then reachable=1; break; fi
228 249 sleep 5
229 250 done
230 251 [ "$reachable" = 1 ] || {
@@ -238,7 +259,7 @@
238 259 # different things, and reporting them the same way is how a scenario run
239 260 # against the wrong disk gets filed as a regression.
240 261 CONSOLE=1
241 - guest 'test -x /usr/bin/alloy' >/dev/null 2>&1 || CONSOLE=0
262 + echo 'test -x /usr/bin/alloy' | guest >/dev/null 2>&1 || CONSOLE=0
242 263
243 264 if [ "$REBOOTED" = 0 ] && [ "$CONSOLE" = 1 ]; then
244 265 # ConditionPathExists=!/usr/bin/alloy was already false, so the unit was a
@@ -252,11 +273,12 @@
252 273 fail "the machine never rebooted, so alloy-layer-components.service did not finish"
253 274 fi
254 275 if [ "$CONSOLE" = 1 ]; then
255 - say "/usr/bin/alloy: present ($(guest '/usr/bin/alloy --version' 2>/dev/null | head -1))"
276 + say "/usr/bin/alloy: present ($(echo '/usr/bin/alloy --version' | guest 2>/dev/null | head -1))"
256 277 else
257 278 fail "/usr/bin/alloy is absent: the machine came up with no console"
258 279 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
280 + echo 'journalctl -u alloy-layer-components.service --no-pager -o cat' \
281 + | guest 2>&1 | tail -40 >&2 || true
260 282 fi
261 283
262 284 # 3. The repo set. Read from both /etc and the image's own /usr/etc, because
@@ -269,7 +291,7 @@
269 291 # quoting. A repo file can carry more than one section, so the id is tracked
270 292 # rather than taken from the filename.
271 293 enabled_repos() {
272 - guest 'sh -s' "$1" <<'AWKEOF' 2>/dev/null || true
294 + guest "$1" <<'AWKEOF' 2>/dev/null || true
273 295 awk '
274 296 /^\[/ { id = substr($0, 2, index($0, "]") - 2) }
275 297 /^enabled[ \t]*=[ \t]*1[ \t]*$/ { if (id != "") print id }
@@ -298,16 +320,43 @@
298 320 # the wizard is standing still and reachable. Copied and then run rather than
299 321 # fed on stdin: sudo wants the password there.
300 322 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"
323 +
324 + # Delivered by writing it through a shell rather than with scp. scp needs the
325 + # sftp subsystem at the far end and a working PATH there, which is a second
326 + # thing to go wrong for no benefit; `cat >` needs a shell, which is already
327 + # proven by every assertion above. Measured 2026-08-26: scp failed here and
328 + # the run reported a machine defect for it.
329 + #
330 + # `sh -c` here rather than `sh -s`, because stdin is carrying the file. The
331 + # string has no metacharacter nushell would take differently, which is what
332 + # makes it safe to send one through.
333 + #
334 + # Two calls, not one, because the password and the script cannot share stdin.
335 + # `sudo -S` reads a bufferful, not a line, so piping the password and then the
336 + # script into `sudo -S bash -s` can have sudo swallow the top of the script.
337 + CI_RAN=0
304 338 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
339 + if ! ssh "${SSH_OPTS[@]}" "$USER_@127.0.0.1" "sh -c 'cat > /tmp/check-installed.sh'" \
340 + < "$REPO_ROOT/build/check-installed.sh"; then
341 + printf 'note: could not write check-installed.sh to the guest (above)\n' >&2
342 + else
343 + CI_RAN=1
344 + guest <<EOF || ci=$?
345 + printf '%s\n' '$PASSWORD' | sudo -S -p '' bash /tmp/check-installed.sh
346 + EOF
347 + fi
348 +
349 + if [ "$CI_RAN" = 0 ]; then
350 + : # reported below, as a run problem rather than a machine one
351 + else
352 + case "$ci" in
353 + 0) say "check-installed: clean" ;;
354 + 1) fail "check-installed found a defect on the installed machine (above)" ;;
355 + # 3 is check-installed's own "could not run" (not root, no restorecon, an
356 + # install from the selinux=0 entry). Its verdict, and not this machine's.
357 + *) printf 'note: check-installed could not run (exit %s)\n' "$ci"; CI_RAN=0 ;;
358 + esac
359 + fi
311 360
312 361 shutdown_vm
313 362
@@ -315,4 +364,13 @@
315 364 printf '\n%s check(s) failed. The machine did not survive an offline first boot.\n' "$FAILED" >&2
316 365 exit 1
317 366 fi
367 +
368 + # The three assertions this test is FOR have passed by here. The fold-in has
369 + # not run, and calling that green would be claiming a check that did not
370 + # happen, while calling it a failure would blame the machine for the harness.
371 + if [ "$CI_RAN" = 0 ]; then
372 + printf '\nThe offline first boot passed: a console was laid down and the repo set is the ordinary one.\n'
373 + die "but check-installed.sh did not run, so the labels are unchecked"
374 + fi
375 +
318 376 printf '\nAn offline first boot laid the console down, left the ordinary repo set, and labelled /etc correctly.\n'
@@ -44,6 +44,7 @@
44 44 import re
45 45 import select
46 46 import signal
47 + import socket
47 48 import struct
48 49 import sys
49 50 import termios
@@ -286,6 +287,49 @@
286 287 self.log.close()
287 288
288 289
290 + class SocketSession(Session):
291 + """The same screen, over a unix socket instead of a pty.
292 +
293 + The guest's serial console is a chardev socket, and on the installer medium
294 + GRUB draws its menu there and `alloy-debug-shell@ttyS0` puts a root bash
295 + there. Both are things a scenario has to read and answer, and neither is
296 + reachable through a pty.
297 +
298 + Subclasses Session for its `wait_for` and `screen`, and replaces only the
299 + transport: there is no child process here, so nothing to fork, size or
300 + reap. The terminal size is the guest's `stty`, not an ioctl from this end.
301 + """
302 +
303 + def __init__(self, path, rows=ROWS, cols=COLS, log_path=None, connect_timeout=60.0):
304 + self.screen = Screen(rows, cols)
305 + self.rows, self.cols = rows, cols
306 + self.log = open(log_path, "wb") if log_path else None
307 + self.eof = False
308 + end = time.time() + connect_timeout
309 + while True:
310 + try:
311 + self.sock = socket.socket(socket.AF_UNIX)
312 + self.sock.connect(path)
313 + break
314 + except (FileNotFoundError, ConnectionRefusedError):
315 + if time.time() >= end:
316 + raise
317 + time.sleep(0.2)
318 + self.fd = self.sock.fileno()
319 +
320 + def send(self, text, settle=0.4):
321 + self.sock.sendall(text.encode())
322 + self.pump(settle)
323 +
324 + def close(self):
325 + try:
326 + self.sock.close()
327 + except OSError:
328 + pass
329 + if self.log:
330 + self.log.close()
331 +
332 +
289 333 def self_test():
290 334 """Check the parser, because a wrong parse still produces a screen.
291 335