From 825de0c50a987ce91153c059cecb280bc8ad3c5d Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Mon, 10 Aug 2026 17:50:04 +0300 Subject: [PATCH 1/4] feat(core): auto-restart a disk VM after its install qemu.Start only flips Installed at the NEXT start, and the installer's poweroff just exits QEMU (-no-reboot). Nothing brought the disk itself up until now: a user had to run `stoat up` a second time by hand. AutoRestartAfterInstall waits for the installer to power off, then restarts the VM so it boots the disk. The guard disarms itself after one restart, since the next load sees Installed already true. --- internal/core/autorestart.go | 46 ++++++++++ internal/core/autorestart_test.go | 145 ++++++++++++++++++++++++++++++ internal/core/wait.go | 5 ++ 3 files changed, 196 insertions(+) create mode 100644 internal/core/autorestart.go create mode 100644 internal/core/autorestart_test.go diff --git a/internal/core/autorestart.go b/internal/core/autorestart.go new file mode 100644 index 0000000..0658563 --- /dev/null +++ b/internal/core/autorestart.go @@ -0,0 +1,46 @@ +package core + +import ( + "context" + "errors" + + "github.com/novusedge/stoat/internal/qemu" +) + +// AutoRestartAfterInstall waits for an uninstalled disk VM's unattended +// installer to power off, then restarts the VM so it boots the disk the +// installer just wrote. +// +// The precondition disarms itself after one restart: qemu.Start flips +// v.Installed the first time it sees real bytes on the disk (run.go's +// diskWritten check), so a second call against the same VM reads Installed +// and returns immediately with no wait. +// +// apkovlBackend.Args adds -no-reboot for this exact boot, so a successful +// install's own "poweroff" (internal/apkovl's installScript) exits QEMU +// instead of re-entering the installer. A failed install leaves the +// installer's shell running, so qemu.Running never turns false and this +// call rides out ctx's deadline instead of restarting. +func AutoRestartAfterInstall(ctx context.Context, name string) (bool, error) { + v, err := load(name) + if err != nil { + return false, err + } + if v.Mode != "disk" || v.Installed || !qemu.Running(v) { + return false, nil + } + + waitCtx, cancel := context.WithTimeout(ctx, InstallTimeout) + defer cancel() + if err := waitStopped(waitCtx, v); err != nil { + // ctx cancellation or InstallTimeout: give up silently, matching + // awaitSSH's contract (internal/tui/autoprov.go) for a watch nobody + // explicitly asked to be told about. + return false, nil + } + + if err := Start(name); err != nil && !errors.Is(err, ErrAlreadyRunning) { + return false, err + } + return true, nil +} diff --git a/internal/core/autorestart_test.go b/internal/core/autorestart_test.go new file mode 100644 index 0000000..cdeaf49 --- /dev/null +++ b/internal/core/autorestart_test.go @@ -0,0 +1,145 @@ +package core + +import ( + "context" + "testing" + "time" + + "github.com/novusedge/stoat/internal/config" +) + +func TestAutoRestartAfterInstallSkipsNonDiskVM(t *testing.T) { + dir := root(t) + v := &config.VM{Name: "work", Mode: "live", RAM: 1024, CPUs: 1, SSHPort: 2400} + if err := v.Save(); err != nil { + t.Fatal(err) + } + v.Dir = dir + "/work" + stop := fakeRunning(t, v) + defer stop() + + start := time.Now() + restarted, err := AutoRestartAfterInstall(context.Background(), "work") + if err != nil { + t.Fatal(err) + } + if restarted { + t.Errorf("restarted = true, want false: not a disk VM") + } + if elapsed := time.Since(start); elapsed > pollInterval { + t.Errorf("took %s, want an immediate no-op", elapsed) + } +} + +func TestAutoRestartAfterInstallSkipsInstalledDiskVM(t *testing.T) { + dir := root(t) + v := &config.VM{Name: "work", Mode: "disk", Installed: true, RAM: 1024, CPUs: 1, SSHPort: 2401} + if err := v.Save(); err != nil { + t.Fatal(err) + } + v.Dir = dir + "/work" + stop := fakeRunning(t, v) + defer stop() + + start := time.Now() + restarted, err := AutoRestartAfterInstall(context.Background(), "work") + if err != nil { + t.Fatal(err) + } + if restarted { + t.Errorf("restarted = true, want false: already installed") + } + if elapsed := time.Since(start); elapsed > pollInterval { + t.Errorf("took %s, want an immediate no-op", elapsed) + } +} + +func TestAutoRestartAfterInstallSkipsWhenNotRunning(t *testing.T) { + dir := root(t) + v := &config.VM{Name: "work", Mode: "disk", Installed: false, RAM: 1024, CPUs: 1, SSHPort: 2402} + if err := v.Save(); err != nil { + t.Fatal(err) + } + v.Dir = dir + "/work" + + start := time.Now() + restarted, err := AutoRestartAfterInstall(context.Background(), "work") + if err != nil { + t.Fatal(err) + } + if restarted { + t.Errorf("restarted = true, want false: qemu is not running") + } + if elapsed := time.Since(start); elapsed > pollInterval { + t.Errorf("took %s, want an immediate no-op", elapsed) + } +} + +// TestAutoRestartAfterInstallGivesUpSilentlyOnTimeout pins the awaitSSH-style +// contract: an installer that never stops must not surface an error, only +// give up once ctx runs out. +func TestAutoRestartAfterInstallGivesUpSilentlyOnTimeout(t *testing.T) { + dir := root(t) + v := &config.VM{Name: "work", Mode: "disk", Installed: false, RAM: 1024, CPUs: 1, SSHPort: 2403} + if err := v.Save(); err != nil { + t.Fatal(err) + } + v.Dir = dir + "/work" + stop := fakeRunning(t, v) + defer stop() + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + start := time.Now() + restarted, err := AutoRestartAfterInstall(ctx, "work") + elapsed := time.Since(start) + if err != nil { + t.Fatalf("err = %v, want nil (silent give-up)", err) + } + if restarted { + t.Errorf("restarted = true, want false: the installer never stopped") + } + if elapsed > 2*time.Second { + t.Fatalf("took %s past a 300ms ctx, want well under a second past it", elapsed) + } +} + +// TestAutoRestartAfterInstallAttemptsStartOnceInstallerStops proves the +// installer-stop signal actually drives a restart attempt, not just the +// guard. qemu.Start has no fake seam, so the restart attempt fails here for +// lack of a real install ISO (v.ISOPath() names nothing on disk); the test +// asserts on that failure's shape rather than a real boot, distinguishing a +// restart ATTEMPT (the failure names v's ISO path) from the guard's silent +// no-op (nil error, near-instant return). +func TestAutoRestartAfterInstallAttemptsStartOnceInstallerStops(t *testing.T) { + dir := root(t) + v := &config.VM{Name: "work", Mode: "disk", Installed: false, OS: "alpine", Backend: "apkovl", RAM: 1024, CPUs: 1, SSHPort: 2404} + if err := v.Save(); err != nil { + t.Fatal(err) + } + v.Dir = dir + "/work" + stop := fakeRunning(t, v) + + go func() { + time.Sleep(2 * pollInterval) + stop() + }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + start := time.Now() + restarted, err := AutoRestartAfterInstall(ctx, "work") + elapsed := time.Since(start) + + if elapsed < 2*pollInterval { + t.Errorf("took %s, want at least %s: it must wait for the installer to stop", elapsed, 2*pollInterval) + } + if restarted { + t.Errorf("restarted = true, want false: the real Start attempt has no ISO to boot") + } + if err == nil { + t.Fatal("err = nil, want a real error proving Start was attempted") + } +} diff --git a/internal/core/wait.go b/internal/core/wait.go index f7ecd8f..8f1ad04 100644 --- a/internal/core/wait.go +++ b/internal/core/wait.go @@ -51,6 +51,11 @@ var ErrCannotReach = errors.New("vm cannot reach the requested state") // interactively (the TUI) does not visibly stall, slow enough not to spin. const pollInterval = 300 * time.Millisecond +// InstallTimeout bounds how long AutoRestartAfterInstall waits for a disk +// VM's unattended installer to power off. setup-alpine plus a package fetch +// runs several minutes on a slow mirror. +const InstallTimeout = 15 * time.Minute + // Wait blocks until VM name reaches the state described by until, or ctx is // cancelled or hits its deadline, whichever comes first. // From efd2749a3ed0cb1fe87ad166d2813cc41159d223 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Mon, 10 Aug 2026 17:50:10 +0300 Subject: [PATCH 2/4] feat(cli): block up on an uninstalled disk VM until its install finishes `stoat up` used to leave a fresh disk VM's install running and exit; the disk never booted until a second, manual `up`. up now blocks on AutoRestartAfterInstall before its usual post-boot apply, so one `up` carries the VM from installer to a provisioned system. --- internal/cli/run_vm.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/cli/run_vm.go b/internal/cli/run_vm.go index 70c0e9a..b4d7606 100644 --- a/internal/cli/run_vm.go +++ b/internal/cli/run_vm.go @@ -92,6 +92,25 @@ func runUp(a *Args, stdout, stderr io.Writer) int { v = started } printDisplay(stdout, core.DisplayFor(v, core.GraphicalSession())) + + // An uninstalled disk VM's own installer is running now, not the system + // `apply` needs to reach. The CLI is a foreground process, so it blocks + // here rather than exiting and leaving the desktop half set up. + // --no-apply skips the recipe run below, not this: the disk must still + // boot the installed system either way. + if v.Mode == "disk" && !v.Installed { + if !a.Quiet { + fmt.Fprintf(stdout, "installing %s (a few minutes)...\n", a.VM) + } + restarted, err := core.AutoRestartAfterInstall(context.Background(), a.VM) + if err != nil || !restarted { + fmt.Fprintf(stdout, "install did not finish; inspect: stoat logs %s\n", a.VM) + return ExitOK + } + if started, err := core.Get(a.VM); err == nil { + v = started + } + } return afterStart(a, v, stdout, stderr) } From e4787a5b73dd18e84137402bb1b966ee658b3bd0 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Mon, 10 Aug 2026 17:50:16 +0300 Subject: [PATCH 3/4] feat(tui): watch an installing disk VM and chain into apply The TUI already auto-applies once a VM answers ssh; an uninstalled disk VM never reached that path, since needsAutoProvision excludes it on purpose (its sshd belongs to the installer, not the system). awaitInstall watches for the installer to power off in the background, the same way awaitSSH already watches for reachability, so the program stays responsive during the wait. Once the restart lands, installRestartedMsg hands off into the existing awaitSSH watch. --- internal/tui/app.go | 18 ++++++ internal/tui/autoprov.go | 26 +++++++++ internal/tui/autoprov_test.go | 103 ++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) diff --git a/internal/tui/app.go b/internal/tui/app.go index 58663a3..cda0348 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -327,12 +327,30 @@ func (m model) updateApp(msg tea.Msg) (tea.Model, tea.Cmd) { m.cloudInit[msg.vm.Name] = "waiting" return m, tea.Batch(started, loadVMs, checkCloudInit(msg.vm)) } + if msg.vm.Mode == "disk" && !msg.vm.Installed { + // The installer is running now, not the system `apply` needs to + // reach. Watch for it to power off and restart into the disk; + // the spinner tick keeps the "installing" line animating while + // that happens. + return m, tea.Batch(started, loadVMs, awaitInstall(msg.vm), m.spin.Tick) + } if !needsAutoProvision(msg.vm) { return m, tea.Batch(started, loadVMs) } // Watch for sshd in the background. The user keeps full use of the UI // meanwhile: provisioning starts on its own once the VM is reachable. return m, tea.Batch(started, loadVMs, awaitSSH(msg.vm)) + case installRestartedMsg: + v := m.vmByName(msg.name) + // Re-check on arrival: the restart itself just happened, but the + // user could have stopped or deleted the VM in the meantime. + if v == nil || v.State != core.StateRunning || !v.Installed { + return m, nil + } + if m.pendingDelete != nil { + return m, nil + } + return m, awaitSSH(*v) case sshReadyMsg: v := m.vmByName(msg.name) // Re-check on arrival: up to 90 seconds have passed, in which the VM diff --git a/internal/tui/autoprov.go b/internal/tui/autoprov.go index 0bdc8c1..16b6238 100644 --- a/internal/tui/autoprov.go +++ b/internal/tui/autoprov.go @@ -18,6 +18,32 @@ import ( // sshReadyMsg says a VM that was just started is now accepting ssh. type sshReadyMsg struct{ name string } +// installRestartedMsg says an uninstalled disk VM's installer powered off +// and stoat restarted it to boot the disk. A nil-error timeout produces no +// message at all, mirroring awaitSSH. +type installRestartedMsg struct{ name string } + +// awaitInstall waits for an uninstalled disk VM's installer to power off, +// then restarts the VM, the same sequence internal/cli/run_vm.go's runUp +// runs synchronously. The TUI runs it as a background watch instead, since +// blocking the whole program for up to core.InstallTimeout would freeze +// every other VM's controls. AutoRestartAfterInstall applies InstallTimeout +// itself, so no ctx here needs its own deadline. +// +// A failure stays silent, matching awaitSSH: the user did not ask for this +// watch, and a failed or still-running installer is visible in the list +// already. +func awaitInstall(v core.VM) tea.Cmd { + name := v.Name + return func() tea.Msg { + restarted, err := core.AutoRestartAfterInstall(context.Background(), name) + if err != nil || !restarted { + return nil + } + return installRestartedMsg{name} + } +} + // awaitSSH waits for sshd on a freshly started VM. // // A failure stays silent. The user did not ask for this watch. A VM that diff --git a/internal/tui/autoprov_test.go b/internal/tui/autoprov_test.go index 127ee61..b560ab0 100644 --- a/internal/tui/autoprov_test.go +++ b/internal/tui/autoprov_test.go @@ -4,6 +4,8 @@ import ( "path/filepath" "testing" + tea "charm.land/bubbletea/v2" + "github.com/novusedge/stoat/internal/core" ) @@ -112,6 +114,107 @@ func TestSSHReadyDoesNothingWhenNothingToProvision(t *testing.T) { } } +// batchLen calls cmd, which must be a tea.Batch, and reports how many +// commands it holds. tea.Batch's own compactCmds collapses a single +// survivor to a bare Cmd rather than a BatchMsg, so this only fits a case +// with more than one command in flight, which vmStartedMsg's branches +// always are (at minimum a toast plus loadVMs). +func batchLen(t *testing.T, cmd tea.Cmd) int { + t.Helper() + msg := cmd() + batch, ok := msg.(tea.BatchMsg) + if !ok { + t.Fatalf("cmd() = %T, want tea.BatchMsg", msg) + } + return len(batch) +} + +// TestVMStartedForUninstalledDiskVMAwaitsInstall pins that an uninstalled +// disk VM takes the install-watch branch, not needsAutoProvision's (which +// already excludes this exact case, so a regression here would fall +// through to the plain started+loadVMs batch instead). +func TestVMStartedForUninstalledDiskVMAwaitsInstall(t *testing.T) { + v := autoVM(t, "disk", []string{"xfce.alpine.sh"}) + v.Installed = false + m := model{screen: screenList, list: newVMList(), spin: newSpinner(), + provisioning: map[string]provState{}, vms: []core.VM{v}} + + out, cmd := m.Update(vmStartedMsg{vm: v}) + _ = out.(model) + if cmd == nil { + t.Fatal("vmStartedMsg returned no command") + } + // started, loadVMs, awaitInstall, spin.Tick: one more than the plain + // started+loadVMs an installed VM with nothing to auto-provision gets. + if n := batchLen(t, cmd); n != 4 { + t.Errorf("batch has %d commands, want 4 (started, loadVMs, awaitInstall, spin tick)", n) + } +} + +// TestVMStartedForInstalledVMSkipsInstallWatch: an already-installed VM must +// not enter the install-watch branch, whether or not it needs a provision +// run. +func TestVMStartedForInstalledVMSkipsInstallWatch(t *testing.T) { + v := autoVM(t, "disk", nil) // Installed: true (autoVM's default), no recipes + m := model{screen: screenList, list: newVMList(), spin: newSpinner(), + provisioning: map[string]provState{}, vms: []core.VM{v}} + + out, cmd := m.Update(vmStartedMsg{vm: v}) + _ = out.(model) + if cmd == nil { + t.Fatal("vmStartedMsg returned no command") + } + if n := batchLen(t, cmd); n != 2 { + t.Errorf("batch has %d commands, want 2 (started, loadVMs)", n) + } +} + +// TestInstallRestartedChainsToAwaitSSH: once the install-restarted VM is +// back up and installed, the handler must hand off into the same awaitSSH +// watch a directly-installed VM gets from vmStartedMsg. +func TestInstallRestartedChainsToAwaitSSH(t *testing.T) { + v := autoVM(t, "disk", []string{"xfce.alpine.sh"}) + m := model{screen: screenList, list: newVMList(), spin: newSpinner(), + provisioning: map[string]provState{}, vms: []core.VM{v}} + + _, cmd := m.Update(installRestartedMsg{name: v.Name}) + if cmd == nil { + t.Error("installRestartedMsg did not chain into awaitSSH") + } +} + +// TestInstallRestartedIgnoresStaleVM: the VM could have been deleted in the +// time it took the installer to power off and stoat to restart it. +func TestInstallRestartedIgnoresStaleVM(t *testing.T) { + m := model{screen: screenList, list: newVMList(), spin: newSpinner(), + provisioning: map[string]provState{}, vms: nil} + + _, cmd := m.Update(installRestartedMsg{name: "gone"}) + if cmd != nil { + t.Error("installRestartedMsg acted on a VM no longer in the list") + } +} + +// TestInstallRestartedRespectsPendingDelete mirrors +// TestSSHReadyNeverPreemptsADeletePrompt: a pending delete confirmation is a +// more consequential question than a background watch resuming. +func TestInstallRestartedRespectsPendingDelete(t *testing.T) { + v := autoVM(t, "disk", []string{"xfce.alpine.sh"}) + m := model{screen: screenList, list: newVMList(), spin: newSpinner(), + provisioning: map[string]provState{}, vms: []core.VM{v}, + pendingDelete: &v, status: "delete vm? y/N"} + + out, cmd := m.Update(installRestartedMsg{name: v.Name}) + after := out.(model) + + if cmd != nil { + t.Error("installRestartedMsg ran ahead of a pending delete confirmation") + } + if after.status != "delete vm? y/N" { + t.Errorf("status = %q, want the delete prompt intact", after.status) + } +} + // TestSSHReadyNeverPreemptsADeletePrompt: an offer arriving on a 90-second // timer must not clear the status line out from under a pending delete // confirmation, a more consequential question the user is mid-answer. From 9504b8ea5352217c72363835a0545e2695938c92 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Mon, 10 Aug 2026 17:50:23 +0300 Subject: [PATCH 4/4] feat: reboot once after a recipe that needs it xfce's install-alpine.sh switches Alpine from mdev to udev (setup-devd), a change OpenRC only picks up at the next boot; without it Xorg finds no mouse or keyboard. A manual restart was the only fix. recipe.toml gets a reboot=true flag. Apply reboots the guest once, after every recipe in a run has succeeded, when any of them declared it, and waits for the guest to answer ssh again before returning. --- internal/core/apply.go | 62 +++++++++++ internal/core/apply_reboot_test.go | 124 ++++++++++++++++++++++ internal/recipes/bundled/xfce/recipe.toml | 1 + internal/recipes/manifest.go | 3 +- internal/recipes/manifest_test.go | 7 ++ 5 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 internal/core/apply_reboot_test.go diff --git a/internal/core/apply.go b/internal/core/apply.go index c82f3b6..d0bfbe1 100644 --- a/internal/core/apply.go +++ b/internal/core/apply.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "os/exec" "strings" "time" @@ -154,6 +155,7 @@ func applyLocked(ctx context.Context, v *config.VM, opts ApplyOpts) error { // recorded. A v1 recipe has no version to record and stays out of // Applied, as it always has. var changed bool + rebootRecipe, needsReboot := "", false for _, name := range runTargets { m, ok := manifests[name] if !ok { @@ -168,13 +170,73 @@ func applyLocked(ctx context.Context, v *config.VM, opts ApplyOpts) error { } v.Applied[name] = config.AppliedRecipe{Version: m.Version, Hash: hash, At: time.Now()} changed = true + if m.Reboot && !needsReboot { + rebootRecipe, needsReboot = name, true + } + } + + // 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 { + if err := rebootAndWait(ctx, v, rebootRecipe); err != nil { + return err + } } + if !changed { return nil } return v.Save() } +// rebootAndWait reboots v's guest over ssh and waits for it to come back. +// +// Some recipes change something the running kernel or init system only +// picks up at boot (xfce's setup-devd switches Alpine's device manager from +// mdev to udev, which Xorg needs for its mouse and keyboard to work). Their +// manifest declares reboot=true so Apply reboots the guest once, here, +// after every recipe in the run has already succeeded. +func rebootAndWait(ctx context.Context, v *config.VM, recipe string) error { + appendProvisionLog(v, fmt.Sprintf("rebooting %s to finish %s...\n", v.Name, recipe)) + + // `reboot` tears down the ssh session before the process can report an + // exit status back to this host, so cmd.Run() returning an error here is + // expected and not a failure signal; only the wait below is. + cmd := exec.CommandContext(ctx, "ssh", sshx.Args(v, "reboot")...) + _ = cmd.Run() + + // The pre-reboot sshd can keep answering for a moment after the reboot + // command returns. This settle avoids waitReachable's first check + // catching that dying instance and returning before the guest has + // actually gone down. + select { + case <-time.After(rebootSettle): + case <-ctx.Done(): + return ctx.Err() + } + + return waitReachable(ctx, v) +} + +// rebootSettle is a guess, not a measurement: real hardware or CI timing +// could need longer for sshd to actually stop answering after `reboot` +// returns control to the caller. +const rebootSettle = 2 * time.Second + +// appendProvisionLog appends s to v's apply log, the same file +// sshx.Provision just wrote to and closed. A failure to open it is not +// fatal to the reboot itself, so this drops the error rather than aborting +// an otherwise successful apply over a log write. +func appendProvisionLog(v *config.VM, s string) { + f, err := os.OpenFile(v.ProvisionLogPath(), os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + f.WriteString(s) +} + // filterByRunMode narrows targets to the recipes that should actually run, // given each recipe's declared run mode (recipes.Manifest.Run) and what v // has already recorded in Applied. diff --git a/internal/core/apply_reboot_test.go b/internal/core/apply_reboot_test.go new file mode 100644 index 0000000..464d3fe --- /dev/null +++ b/internal/core/apply_reboot_test.go @@ -0,0 +1,124 @@ +package core + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novusedge/stoat/internal/config" +) + +// installFakeSSHClient puts a stand-in "ssh" on PATH ahead of the real one, +// mirroring internal/sshx/sshx_test.go's installFakeSSH. It exits 0 for +// every invocation: a recipe run (its last two args are "sh" "-s") reads and +// discards stdin the way a real remote shell would consume it; a `reboot` +// invocation (its last arg is "reboot") exits immediately, standing in for +// ssh losing its connection when the guest actually reboots. +func installFakeSSHClient(t *testing.T) { + t.Helper() + bin := t.TempDir() + script := "#!/bin/sh\n" + + "last=\"\"\n" + + "for a in \"$@\"; do last=\"$a\"; done\n" + + "[ \"$last\" = reboot ] && exit 0\n" + + "cat >/dev/null\n" + + "exit 0\n" + if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+":"+os.Getenv("PATH")) +} + +// writeV2RecipeWithReboot is writeV2Recipe (apply_test.go) plus the +// reboot=true line neither that helper nor recipes' own writeV2Recipe +// support. +func writeV2RecipeWithReboot(t *testing.T, rootDir, name string) { + t.Helper() + recipeDir := filepath.Join(rootDir, "recipes", name) + if err := os.MkdirAll(recipeDir, 0o755); err != nil { + t.Fatal(err) + } + toml := "name = \"" + name + "\"\n" + + "version = \"1.0\"\n" + + "script = \"install.sh\"\n" + + "run = \"always\"\n" + + "reboot = true\n" + if err := os.WriteFile(filepath.Join(recipeDir, "recipe.toml"), []byte(toml), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(recipeDir, "install.sh"), []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil { + t.Fatal(err) + } +} + +// TestApplyRebootsAfterARecipeThatDeclaresIt exercises the finalize step +// end to end: a successful run of a reboot=true recipe reboots the guest +// and waits for it to come back, logging both. +func TestApplyRebootsAfterARecipeThatDeclaresIt(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 work to finish xfce") { + t.Errorf("provision log = %q, want a rebooting line naming xfce", 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. +func TestApplySkipsRebootWhenNoRecipeDeclaresIt(t *testing.T) { + dir := root(t) + writeV2Recipe(t, dir, "tool", "always", "1.0", "#!/bin/sh\necho hi\n") + 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{"tool"}, + } + 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("provision log = %q, want no reboot line: no recipe declared reboot=true", log) + } +} diff --git a/internal/recipes/bundled/xfce/recipe.toml b/internal/recipes/bundled/xfce/recipe.toml index dbed01c..8c96412 100644 --- a/internal/recipes/bundled/xfce/recipe.toml +++ b/internal/recipes/bundled/xfce/recipe.toml @@ -3,6 +3,7 @@ description = "XFCE desktop with autologin startx on tty1" os = ["alpine", "ubuntu", "debian", "arch"] stage = "provision" script = "install.sh" +reboot = true [scripts] alpine = "install-alpine.sh" diff --git a/internal/recipes/manifest.go b/internal/recipes/manifest.go index 0c0e9d4..9823ef9 100644 --- a/internal/recipes/manifest.go +++ b/internal/recipes/manifest.go @@ -21,7 +21,8 @@ type Manifest struct { Script string `toml:"script"` Scripts map[string]string `toml:"scripts"` // OS-specific overrides Auto bool `toml:"auto"` - Run string `toml:"run"` // "once" | "always" | "manual" + Run string `toml:"run"` // "once" | "always" | "manual" + Reboot bool `toml:"reboot"` // guest needs a reboot after this recipe to take effect dir string // recipe directory, set by ParseManifest; scripts resolve against it } diff --git a/internal/recipes/manifest_test.go b/internal/recipes/manifest_test.go index c7c348f..d2b7630 100644 --- a/internal/recipes/manifest_test.go +++ b/internal/recipes/manifest_test.go @@ -30,6 +30,7 @@ stage = "install" script = "install.sh" auto = true run = "always" +reboot = true [scripts] alpine = "install-alpine.sh" @@ -68,6 +69,9 @@ alpine = "install-alpine.sh" if m.Scripts["alpine"] != "install-alpine.sh" { t.Errorf("Scripts[alpine] = %q, want install-alpine.sh", m.Scripts["alpine"]) } + if !m.Reboot { + t.Error("Reboot = false, want true") + } } func TestParseManifestDefaults(t *testing.T) { @@ -89,6 +93,9 @@ script = "install.sh" if m.Auto { t.Error("Auto = true, want default false") } + if m.Reboot { + t.Error("Reboot = true, want default false") + } } func TestParseManifestMissingName(t *testing.T) {