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) } 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/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. // 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) { 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.