max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 |