Repository navigation
feat: typed error codes, status enums, screenshots, boot fixes #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f0333c7
e4c0282
c7edffe
28ce4ff
5a4aa71
75f9509
3426a39
159cc70
5cfa8e3
6b58fdc
f1a96fc
5c96562
ee7c1de
d5ee52d
61e0b40
d36557c
f25c6e7
072502a
514b8d4
47c42ba
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,6 +45,7 @@ usage: stoat <command> [flags] | |
| | [`guest ls`](#stoat-guest-ls) | List loaded guest OS definitions | 0 | | ||
| | [`guest show`](#stoat-guest-show-name) | Print one guest's merged definition | 0, 1 | | ||
| | [`logs`](#stoat-logs-name--n-n) | Tail a VM's log, or stoat's own | 0, 1 | | ||
| | [`screenshot`](#stoat-screenshot-name--o-path) | Write the VM's screen to a PNG | 0, 1 | | ||
| | [`doctor`](#stoat-doctor) | Check host prerequisites | 0, 1 | | ||
| | [`version`](#stoat-version) | Print the stoat version | 0 | | ||
| | [`help`](#stoat-help) | Show the usage message | 0 | | ||
|
|
@@ -540,6 +541,21 @@ $ stoat logs -n 20 | |
|
|
||
| **Exit codes:** 0 on success (including an empty log, which prints nothing); 1 if the log can't be opened or read. | ||
|
|
||
| ## `stoat screenshot <name> [-o path]` | ||
|
|
||
| Writes the VM's screen to a PNG and prints the path, the pixel size and the byte count. qemu dumps its own framebuffer over the monitor socket, so the image is the same whether the display is a GTK window or a VNC socket, and a VM stuck at a boot prompt still answers. | ||
|
|
||
| ``` | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Set a language for this fenced block.
🧰 Tools🪛 markdownlint-cli2 (0.23.2)[warning] 548-548: Fenced code blocks should have a language specified (MD040, fenced-code-language) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| $ stoat screenshot work | ||
| /home/u/.stoat/work/screenshots/2026-09-05T140302Z.png (1280x800, 48213 bytes) | ||
| ``` | ||
|
|
||
| Without `-o`, the file lands in `<vm dir>/screenshots/`, named for the second it was taken: RFC3339 with the colons stripped, since a colon in a filename breaks scp's `host:path` split. A second shot inside the same second gets `-2`, then `-3`, so a caller polling a boot never overwrites the frame it just took. | ||
|
|
||
| `-o` names the file instead. qemu writes it as its own user, so a relative path resolves against the caller's working directory before qemu sees it. | ||
|
|
||
| **Exit codes:** 0 on success; 1 if the VM does not exist, is not running (`not_running`), or qemu refuses the dump (`screenshot_failed`). | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Document the reachable If QEMU exits after its running check and before Add 🤖 Prompt for AI Agents |
||
|
|
||
| ## `stoat doctor` | ||
|
|
||
| Checks host prerequisites: qemu/KVM, `qemu-img`, `ssh`, `xorriso` and `/dev/kvm`, the same set the installer's own checklist runs, so `stoat doctor` and `just setup` can't disagree about whether the host is ready. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -77,7 +77,7 @@ echo "stoat: installing Alpine unattended, this takes a few minutes..." | |
| # here so the child inherits it and repartitions /dev/vda unattended. | ||
| export ERASE_DISKS=/dev/vda | ||
| if setup-alpine -e -f /etc/stoat/answerfile; then | ||
| echo "$(date -Iseconds)" > /mnt/work/.installed | ||
| ` + postInstall + ` echo "$(date -Iseconds)" > /mnt/work/.installed | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Do not record completion after a failed post-install. If Make root discovery and required post-install operations return failure. Write 🤖 Prompt for AI Agents |
||
| echo "stoat: install complete, powering off; run 'stoat up' to boot the disk" | ||
| poweroff | ||
| else | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| package apkovl | ||
|
|
||
| // postInstall runs on the installed disk after setup-alpine succeeds and | ||
| // before the installer powers off. It is sh, and it runs in the live | ||
| // installer environment, so every path it touches is under $target. | ||
| // | ||
| // setup-disk leaves nothing mounted for this to reuse, and the partition | ||
| // layout is whatever setup-disk chose, so mountTarget finds the root by | ||
| // looking for /etc/inittab rather than by assuming a partition number. The | ||
| // installer environment has no /etc/mtab worth reading either: the target | ||
| // was mounted and unmounted by a child process. | ||
| const postInstall = mountTarget + extlinuxTimeoutFix + workMountFix + issueFix + umountTarget | ||
|
|
||
| const mountTarget = `target=/mnt/target | ||
| mkdir -p "$target" | ||
| root= | ||
| for p in /dev/vda3 /dev/vda2 /dev/vda1; do | ||
| [ -b "$p" ] || continue | ||
| mount "$p" "$target" 2>/dev/null || continue | ||
| if [ -f "$target/etc/inittab" ]; then root=$p; break; fi | ||
| umount "$target" | ||
| done | ||
| if [ -z "$root" ]; then | ||
| echo "stoat: no installed root found on /dev/vda; skipping the post-install fixes" | ||
| else | ||
| # /boot is inside the root on a single-partition install and its own | ||
| # partition otherwise. Either way extlinux.conf must be writable. | ||
| [ -f "$target/boot/extlinux.conf" ] || mount /dev/vda1 "$target/boot" 2>/dev/null || true | ||
| ` | ||
|
|
||
| const umountTarget = ` umount "$target/boot" 2>/dev/null || true | ||
| umount "$target" | ||
| fi | ||
| ` | ||
|
|
||
| // extlinuxTimeoutFix appends TOTALTIMEOUT to the installed extlinux.conf | ||
| // (issue #59). The installed config carries DEFAULT menu.c32, PROMPT 0, | ||
| // MENU HIDDEN and TIMEOUT 10, one second. syslinux cancels TIMEOUT and draws | ||
| // the hidden menu when input arrives during that second, and then waits with | ||
| // no countdown left. TOTALTIMEOUT fires whatever the user typed. | ||
| // | ||
| // update-extlinux.conf has no key for TOTALTIMEOUT, so the line is appended | ||
| // to the generated file. A kernel upgrade that reruns update-extlinux drops | ||
| // it again. | ||
| const extlinuxTimeoutFix = ` conf="$target/boot/extlinux.conf" | ||
| if [ -f "$conf" ] && ! grep -q '^TOTALTIMEOUT ' "$conf"; then | ||
| echo 'TOTALTIMEOUT 100' >> "$conf" | ||
| echo "stoat: added TOTALTIMEOUT to extlinux.conf" | ||
| fi | ||
| ` | ||
|
|
||
| // workMountFix repairs the installed fstab's 9p lines (issue #60). | ||
| // | ||
| // setup-disk writes the target's fstab from the live system's mounts, and | ||
| // stoat's shares sit under /mnt, the same directory setup-disk mounts the | ||
| // target on, so the work share lands as "work /work 9p ... 0 2". busybox | ||
| // fsck then has no fsck.9p helper, and /work does not exist on the target, | ||
| // so the guest prints a failed mount on every boot. | ||
| // | ||
| // awk rewrites only lines whose type field is 9p; every other line prints | ||
| // unchanged, comments included. | ||
| const workMountFix = ` fstab="$target/etc/fstab" | ||
| if [ -f "$fstab" ]; then | ||
| awk '$3 == "9p" { $2 = "/mnt/" $1; $5 = "0"; $6 = "0" } { print }' "$fstab" > "$fstab.stoat" && | ||
| mv "$fstab.stoat" "$fstab" | ||
| awk '$3 == "9p" { print $2 }' "$fstab" | while read -r d; do | ||
| mkdir -p "$target$d" | ||
| done | ||
| fi | ||
| ` | ||
|
|
||
| // issueFix restores the stock /etc/issue on the target (issue #61). | ||
| // | ||
| // setup-disk -m sys copies the live system's /etc, so the installed login | ||
| // screen still reads "Installing Alpine. Unattended. Do not log in." The | ||
| // guard on the banner text means a rerun never overwrites an issue the user | ||
| // has since edited. | ||
| // | ||
| // printf's format is single-quoted, so \r, \m and \l reach the file as the | ||
| // getty escapes \r, \m and \l rather than as control characters. | ||
| const issueFix = ` issue="$target/etc/issue" | ||
| if [ -f "$issue" ] && grep -q 'Installing Alpine' "$issue"; then | ||
| printf 'Welcome to Alpine Linux %s\nKernel \\r on an \\m (\\l)\n\n' \ | ||
| "$(cut -d. -f1,2 "$target/etc/alpine-release")" > "$issue" | ||
| echo "stoat: restored the stock /etc/issue" | ||
| fi | ||
| ` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| package apkovl | ||
|
|
||
| import ( | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
|
||
| // runFix runs one post-install fragment against a fake target root, the way | ||
| // the installer runs it, and returns nothing: a failing shell fails the test. | ||
| func runFix(t *testing.T, target, fragment string) { | ||
| t.Helper() | ||
| if _, err := exec.LookPath("sh"); err != nil { | ||
| t.Skip("sh not installed") | ||
| } | ||
| cmd := exec.Command("sh", "-c", "set -e\ntarget="+target+"\n"+fragment) | ||
| if out, err := cmd.CombinedOutput(); err != nil { | ||
| t.Fatalf("fragment failed: %v\n%s", err, out) | ||
| } | ||
| } | ||
|
|
||
| // syslinux cancels TIMEOUT when a key arrives during the countdown and then | ||
| // waits forever. TOTALTIMEOUT fires whatever the user typed. The install runs | ||
| // the append on a disk that may already carry the line, so it must not stack. | ||
| func TestExtlinuxTimeoutFixIsIdempotent(t *testing.T) { | ||
| target := t.TempDir() | ||
| if err := os.MkdirAll(filepath.Join(target, "boot"), 0o755); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| conf := filepath.Join(target, "boot", "extlinux.conf") | ||
| if err := os.WriteFile(conf, []byte("DEFAULT menu.c32\nPROMPT 0\nMENU HIDDEN\nTIMEOUT 10\n"), 0o644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| runFix(t, target, extlinuxTimeoutFix) | ||
| runFix(t, target, extlinuxTimeoutFix) | ||
|
|
||
| b, err := os.ReadFile(conf) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if n := strings.Count(string(b), "TOTALTIMEOUT"); n != 1 { | ||
| t.Errorf("TOTALTIMEOUT appears %d times, want 1:\n%s", n, b) | ||
| } | ||
| } | ||
|
|
||
| // The installed fstab carries stoat's work share as "work /work 9p ... 0 2": | ||
| // setup-disk writes the target's fstab from the live system's mounts, and | ||
| // stoat mounts its shares under /mnt, the directory setup-disk mounts the | ||
| // target on. fsck then looks for fsck.9p and /work does not exist, so the | ||
| // guest reports a failed mount on every boot. | ||
| func TestWorkMountFixRepairsTheInstalledFstab(t *testing.T) { | ||
| target := t.TempDir() | ||
| if err := os.MkdirAll(filepath.Join(target, "etc"), 0o755); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| fstab := filepath.Join(target, "etc", "fstab") | ||
| body := "/dev/vda3 / ext4 rw,relatime 0 1\n" + | ||
| "work /work 9p trans=virtio,version=9p2000.L,rw,nofail 0 2\n" | ||
| if err := os.WriteFile(fstab, []byte(body), 0o644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| runFix(t, target, workMountFix) | ||
| runFix(t, target, workMountFix) | ||
|
|
||
| b, err := os.ReadFile(fstab) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| got := string(b) | ||
| if !strings.Contains(got, "work /mnt/work 9p") { | ||
| t.Errorf("fstab does not point work at /mnt/work:\n%s", got) | ||
| } | ||
| for _, line := range strings.Split(strings.TrimRight(got, "\n"), "\n") { | ||
| f := strings.Fields(line) | ||
| if len(f) >= 6 && f[2] == "9p" && f[5] != "0" { | ||
| t.Errorf("9p line has fsck pass %q, want 0: %s", f[5], line) | ||
| } | ||
| } | ||
| if !strings.Contains(got, "/dev/vda3 / ext4 rw,relatime 0 1") { | ||
| t.Errorf("the root line was rewritten:\n%s", got) | ||
| } | ||
| if _, err := os.Stat(filepath.Join(target, "mnt", "work")); err != nil { | ||
| t.Errorf("mountpoint was not created: %v", err) | ||
| } | ||
| } | ||
|
|
||
| // setup-disk -m sys copies the live /etc onto the target, so the installer | ||
| // banner is still what the installed system's login screen shows. | ||
| func TestIssueFixRestoresTheStockBanner(t *testing.T) { | ||
| target := t.TempDir() | ||
| if err := os.MkdirAll(filepath.Join(target, "etc"), 0o755); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| issue := filepath.Join(target, "etc", "issue") | ||
| if err := os.WriteFile(issue, []byte(installIssue), 0o644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if err := os.WriteFile(filepath.Join(target, "etc", "alpine-release"), []byte("3.22.1\n"), 0o644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| runFix(t, target, issueFix) | ||
|
|
||
| b, err := os.ReadFile(issue) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| got := string(b) | ||
| if strings.Contains(got, "Installing Alpine") { | ||
| t.Errorf("the installer banner survived:\n%s", got) | ||
| } | ||
| if !strings.Contains(got, "Welcome to Alpine Linux 3.22") { | ||
| t.Errorf("issue = %q, want the stock banner", got) | ||
| } | ||
|
|
||
| // A second run must leave a banner the user may have edited alone. | ||
| if err := os.WriteFile(issue, []byte("my own banner\n"), 0o644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| runFix(t, target, issueFix) | ||
| b, err = os.ReadFile(issue) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if string(b) != "my own banner\n" { | ||
| t.Errorf("issue = %q, want the user's own banner untouched", b) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Complete the documented error taxonomy.
Add
core.ErrInvalidSpecandqemu.ErrNoXattrto these lists. Both have stable wire-code mappings, but the design document omits them.🤖 Prompt for AI Agents