Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/apkovl/apkovl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion internal/core/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
37 changes: 36 additions & 1 deletion internal/core/apply_reboot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
21 changes: 11 additions & 10 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading