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
19 changes: 19 additions & 0 deletions internal/cli/run_vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
62 changes: 62 additions & 0 deletions internal/core/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"

Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
124 changes: 124 additions & 0 deletions internal/core/apply_reboot_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
46 changes: 46 additions & 0 deletions internal/core/autorestart.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading