max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+1250 insertions,
-6 deletions
| @@ -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() |