From c065c18056c44917c08a30035d2c407e0fb6223e Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Mon, 10 Aug 2026 20:39:54 +0300 Subject: [PATCH] fix: stop the install script from re-running on the installed disk setup-disk -m sys copies the live system, including /etc/local.d/stoat-install.start, onto the installed disk. On the disk boot the script's /mnt/work/.installed guard failed, because the 9p work share is not mounted yet, so it re-ran setup-alpine. That apk activity raced the recipe apply for the database lock, so the apply failed exit 99 and the udev switch never landed. A disk-root guard now makes the script exit at once on the installed disk; only the installer's tmpfs root proceeds. Also gate the reboot-once finalize to disk mode: a live VM's root is a tmpfs the reboot wipes, and a live VM re-applies every boot, so a reboot there would loop. Also center the TUI frame on both axes, the user's preference over the top-anchored frame. --- internal/apkovl/apkovl.go | 8 +++++++ internal/core/apply.go | 6 ++++- internal/core/apply_reboot_test.go | 37 +++++++++++++++++++++++++++++- internal/tui/app.go | 21 +++++++++-------- 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/internal/apkovl/apkovl.go b/internal/apkovl/apkovl.go index 9f0ee72..cdd4ac5 100644 --- a/internal/apkovl/apkovl.go +++ b/internal/apkovl/apkovl.go @@ -59,6 +59,14 @@ const legacyTmpName = "stoat.apkovl.tar.gz.tmp" // not power off or mark itself done. const installScript = `#!/bin/sh [ -c /dev/ttyS0 ] && exec >/dev/ttyS0 2>&1 +# setup-disk -m sys copies the live system, including this /etc/local.d +# script, onto the installed disk. A real disk root means the install already +# ran, so exit before setup-alpine reruns and its apk fights the recipe apply +# for the database lock. Only the installer environment has a tmpfs root. +case "$(awk '$2 == "/" { print $3 }' /proc/mounts)" in +tmpfs | overlay) ;; +*) exit 0 ;; +esac if [ -f /mnt/work/.installed ]; then echo "stoat: install already recorded on the work share, skipping" exit 0 diff --git a/internal/core/apply.go b/internal/core/apply.go index d0bfbe1..eceb4db 100644 --- a/internal/core/apply.go +++ b/internal/core/apply.go @@ -178,7 +178,11 @@ func applyLocked(ctx context.Context, v *config.VM, opts ApplyOpts) error { // One recipe declaring reboot=true is enough: the guest reboots once, // not once per such recipe, so the first one found names the reboot in // the log. - if needsReboot { + // + // Disk only: a live VM's root is a tmpfs the reboot wipes, and a live VM + // re-applies every boot, so a reboot here would loop. A live desktop + // recipe restarts its session in place instead (xfce's kill -HUP 1). + if needsReboot && v.Mode == "disk" { if err := rebootAndWait(ctx, v, rebootRecipe); err != nil { return err } diff --git a/internal/core/apply_reboot_test.go b/internal/core/apply_reboot_test.go index 464d3fe..e83aef6 100644 --- a/internal/core/apply_reboot_test.go +++ b/internal/core/apply_reboot_test.go @@ -65,7 +65,7 @@ func TestApplyRebootsAfterARecipeThatDeclaresIt(t *testing.T) { defer stop() v := &config.VM{ - Name: "work", Mode: "live", OS: "alpine", Backend: "apkovl", + Name: "work", Mode: "disk", OS: "alpine", Backend: "apkovl", Installed: true, RAM: 512, CPUs: 1, SSHPort: port, Recipes: []string{"xfce"}, } if err := v.Save(); err != nil { @@ -88,6 +88,41 @@ func TestApplyRebootsAfterARecipeThatDeclaresIt(t *testing.T) { } } +// TestApplyDoesNotRebootALiveVM pins the mode gate: a live VM's root is a +// tmpfs the reboot wipes, and a live VM re-applies every boot, so a reboot +// here would loop. A reboot=true recipe on a live VM reboots nothing. +func TestApplyDoesNotRebootALiveVM(t *testing.T) { + dir := root(t) + writeV2RecipeWithReboot(t, dir, "xfce") + installFakeSSHClient(t) + + port, stop := fakeSSHD(t, 0) + defer stop() + + v := &config.VM{ + Name: "work", Mode: "live", OS: "alpine", Backend: "apkovl", + RAM: 512, CPUs: 1, SSHPort: port, Recipes: []string{"xfce"}, + } + if err := v.Save(); err != nil { + t.Fatal(err) + } + v.Dir = dir + "/work" + stopRunning := fakeRunning(t, v) + defer stopRunning() + + if err := Apply(context.Background(), "work", ApplyOpts{}); err != nil { + t.Fatalf("Apply: %v", err) + } + + log, err := os.ReadFile(v.ProvisionLogPath()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(log), "rebooting") { + t.Errorf("a live VM rebooted: %q", log) + } +} + // TestApplySkipsRebootWhenNoRecipeDeclaresIt is the negative case: a run // with no reboot=true recipe among runTargets must not touch ssh a second // time or log anything about rebooting. diff --git a/internal/tui/app.go b/internal/tui/app.go index cda0348..a29bcec 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -472,20 +472,21 @@ func (m model) View() tea.View { s = lipgloss.JoinVertical(lipgloss.Center, banner(), "", body) } - // Anchored to the top, not centered: a centered screen re-centers - // vertically every time its height changes, e.g. a toast, a progress - // panel, or a search prompt appearing. Anchoring to the top means only - // content below the change moves, not the whole screen. - const topMargin = 1 - margin := topMargin - if lipgloss.Height(s)+margin > m.height { - margin = 0 + // Centered on both axes. A centered screen re-centers vertically when its + // height changes (a toast, a progress panel, a search prompt), so content + // shifts as those appear. The user prefers a centered frame to a + // top-anchored one, so the shift is the accepted cost. + // + // Content taller than the terminal falls back to a top anchor, or Place + // would clip the top and leave it unreachable. + vAlign := lipgloss.Center + if lipgloss.Height(s) > m.height { + vAlign = lipgloss.Top } - s = strings.Repeat("\n", margin) + s // Overlays go on last, over the finished screen: they must not be part of // what Place centers. The modal sits under the toast, so a toast raised // while the picker is open is still readable. - return m.newView(m.renderToast(m.renderModal(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Top, s)))) + return m.newView(m.renderToast(m.renderModal(lipgloss.Place(m.width, m.height, lipgloss.Center, vAlign, s)))) } // newView wraps a rendered frame in a tea.View with the alt-screen flag set,