From b2ae4ea3f5eb3725ae7629e9ed813e24be9f4509 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Mon, 10 Aug 2026 00:26:30 +0300 Subject: [PATCH 1/3] feat(core): add NeedsProvision to gate an automatic provision run NeedsProvision reports whether a provision run on a VM would do real work: a recipe filterByRunMode would still run, or a disk VM has a share whose idempotent mount step leaves no Applied record to check. A cloud VM always returns false, since cloud-init already ran its recipes from the seed at first boot. TUI and CLI callers use this next to decide when to provision without asking. --- internal/core/needs_provision.go | 27 +++++++ internal/core/needs_provision_test.go | 105 ++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 internal/core/needs_provision.go create mode 100644 internal/core/needs_provision_test.go diff --git a/internal/core/needs_provision.go b/internal/core/needs_provision.go new file mode 100644 index 0000000..c631331 --- /dev/null +++ b/internal/core/needs_provision.go @@ -0,0 +1,27 @@ +package core + +import "github.com/novusedge/stoat/internal/config" + +// NeedsProvision reports whether a provision run on v would do any work. +// +// True covers two cases: a recipe filterByRunMode would still run (never +// applied, or applied against a script that has since changed), or v is a +// disk VM with a share set, since sshx.Provision's share-mount step is +// idempotent but leaves no Applied entry to check. +// +// A cloud VM always returns false. cloud-init applies its recipes from the +// seed at first boot, matching the guard autoprov.go's +// wantsAutoProvisionPrompt uses. +func NeedsProvision(v *config.VM) (bool, error) { + if v.Mode == "cloud" { + return false, nil + } + runTargets, _, err := filterByRunMode(v, v.Recipes, nil) + if err != nil { + return false, err + } + if len(runTargets) > 0 { + return true, nil + } + return v.Mode == "disk" && v.Share != "", nil +} diff --git a/internal/core/needs_provision_test.go b/internal/core/needs_provision_test.go new file mode 100644 index 0000000..80865fa --- /dev/null +++ b/internal/core/needs_provision_test.go @@ -0,0 +1,105 @@ +package core + +import ( + "testing" + + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/recipes" +) + +// TestNeedsProvisionNothingApplied: a fresh VM has a recipe that has never +// run, so provisioning it does real work. +func TestNeedsProvisionNothingApplied(t *testing.T) { + dir := root(t) + writeV2Recipe(t, dir, "tool", "once", "1.0", "#!/bin/sh\necho one\n") + v := &config.VM{Mode: "live", OS: "alpine", Recipes: []string{"tool"}} + + got, err := NeedsProvision(v) + if err != nil { + t.Fatal(err) + } + if !got { + t.Error("got false, want true: the recipe has never run") + } +} + +// TestNeedsProvisionAllAppliedNoShare: every recipe already ran at its +// current script hash and there is no share to mount, so nothing is left +// for a provision run to do. +func TestNeedsProvisionAllAppliedNoShare(t *testing.T) { + dir := root(t) + writeV2Recipe(t, dir, "tool", "once", "1.0", "#!/bin/sh\necho one\n") + hash, err := recipes.ScriptHash("tool", "alpine") + if err != nil { + t.Fatal(err) + } + v := &config.VM{ + Mode: "live", OS: "alpine", Recipes: []string{"tool"}, + Applied: map[string]config.AppliedRecipe{"tool": {Version: "1.0", Hash: hash}}, + } + + got, err := NeedsProvision(v) + if err != nil { + t.Fatal(err) + } + if got { + t.Error("got true, want false: the recipe already ran and there is no share") + } +} + +// TestNeedsProvisionChangedRecipe pins the case filterByRunMode was extended +// for: a script fixed after it was applied must run again. +func TestNeedsProvisionChangedRecipe(t *testing.T) { + dir := root(t) + writeV2Recipe(t, dir, "tool", "once", "1.0", "#!/bin/sh\necho fixed\n") + v := &config.VM{ + Mode: "live", OS: "alpine", Recipes: []string{"tool"}, + Applied: map[string]config.AppliedRecipe{"tool": {Version: "1.0", Hash: "stale"}}, + } + + got, err := NeedsProvision(v) + if err != nil { + t.Fatal(err) + } + if !got { + t.Error("got false, want true: the script hash no longer matches") + } +} + +// TestNeedsProvisionDiskWithShareEvenWhenApplied: a disk VM's share mount is +// idempotent but not tracked in Applied, so it always counts as work. +func TestNeedsProvisionDiskWithShareEvenWhenApplied(t *testing.T) { + dir := root(t) + writeV2Recipe(t, dir, "tool", "once", "1.0", "#!/bin/sh\necho one\n") + hash, err := recipes.ScriptHash("tool", "alpine") + if err != nil { + t.Fatal(err) + } + v := &config.VM{ + Mode: "disk", OS: "alpine", Recipes: []string{"tool"}, Share: "/host/path", + Applied: map[string]config.AppliedRecipe{"tool": {Version: "1.0", Hash: hash}}, + } + + got, err := NeedsProvision(v) + if err != nil { + t.Fatal(err) + } + if !got { + t.Error("got false, want true: a disk VM with a share still needs the mount step") + } +} + +// TestNeedsProvisionCloud: cloud-init applies a cloud VM's recipes at first +// boot, so there is never anything left for an ssh-based provision run. +func TestNeedsProvisionCloud(t *testing.T) { + root(t) + v := &config.VM{Mode: "cloud", OS: "debian", Recipes: []string{"xfce"}} + + got, err := NeedsProvision(v) + if err != nil { + t.Fatal(err) + } + if got { + t.Error("got true, want false: a cloud VM provisions through cloud-init") + } +} From 083c21e603ccb8b50c3dddc6aa084cfc5082062d Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Mon, 10 Aug 2026 00:26:36 +0300 Subject: [PATCH 2/3] feat(tui): apply provisioning automatically on boot sshReadyMsg now starts a provision run itself instead of showing a y/N prompt. needsAutoProvision replaces wantsAutoProvisionPrompt: it defers to core.NeedsProvision, except for a live VM, whose tmpfs root wipes every reboot, so its host-side Applied record cannot answer for what the guest still has. A live VM with any recipe always auto-provisions. Removed autoProvisionPrompt, lastProvisionSucceeded, the model's pendingProvision field, and list.go's y/N key handling for it: there is no prompt left to answer. ensureNoStaleLog is unchanged. --- internal/tui/app.go | 19 ++-- internal/tui/autoprov.go | 87 +++++++---------- internal/tui/autoprov_test.go | 170 ++++++++++++++-------------------- internal/tui/list.go | 13 --- internal/tui/snapshots.go | 5 +- 5 files changed, 112 insertions(+), 182 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 5bcd208..018db47 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -54,8 +54,7 @@ type model struct { // broken VMs too: core.Destroy takes a directory name and handles both, so // there is no longer a second "which kind of row is this" state to keep // mutually exclusive with this one. - pendingDelete *core.VM - pendingProvision *core.VM // VM that just became reachable, awaiting a y/N to provision + pendingDelete *core.VM // provisioning tracks VMs with a provision run in flight. It is keyed by // the directory name core.VM.Name reports, like cloudInit below. This @@ -327,32 +326,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 !wantsAutoProvisionPrompt(msg.vm) { + 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: this is an offer that arrives when it is ready, not a - // modal wait. + // meanwhile: provisioning starts on its own once the VM is reachable. return m, tea.Batch(started, loadVMs, awaitSSH(msg.vm)) case sshReadyMsg: v := m.vmByName(msg.name) // Re-check on arrival: up to 90 seconds have passed, in which the VM // could have been stopped, deleted, edited to drop its recipes, or // provisioned by hand. - if v == nil || v.State != core.StateRunning || !wantsAutoProvisionPrompt(*v) { + if v == nil || v.State != core.StateRunning || !needsAutoProvision(*v) { return m, nil } if _, busy := m.provisioning[v.Name]; busy { return m, nil } - // Never stack prompts: a pending delete is a more consequential - // question and the user is mid-answer. + // A pending delete is a more consequential question and the user is + // mid-answer; starting a provision now would clear m.status and hide + // the confirmation. if m.pendingDelete != nil { return m, nil } - m.pendingProvision = v - m.status = autoProvisionPrompt(*v) - return m, nil + return m, m.startProvision(*v) case provisionDoneMsg: delete(m.provisioning, msg.name) if msg.err != nil { diff --git a/internal/tui/autoprov.go b/internal/tui/autoprov.go index d58b257..0bdc8c1 100644 --- a/internal/tui/autoprov.go +++ b/internal/tui/autoprov.go @@ -3,19 +3,17 @@ package tui import ( "context" "os" - "strings" tea "charm.land/bubbletea/v2" + "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/core" "github.com/novusedge/stoat/internal/sshx" ) -// After a VM with recipes starts, stoat watches for sshd. It then offers to -// provision the VM. It does not provision without asking. -// -// An unasked shell script inside a guest looks like a bug the first time it -// runs. A recipe can take minutes and install hundreds of packages. +// After a VM starts, stoat watches for sshd and provisions it once it is +// reachable, with no keypress. A cloud VM never reaches this path: cloud-init +// applies its recipes from the seed at first boot instead. // sshReadyMsg says a VM that was just started is now accepting ssh. type sshReadyMsg struct{ name string } @@ -44,71 +42,56 @@ func awaitSSH(v core.VM) tea.Cmd { } } -// wantsAutoProvisionPrompt reports whether stoat should offer to provision v -// once it is reachable. -// -// The answer differs by mode because the filesystem differs by mode: +// needsAutoProvision reports whether stoat should provision v once it is +// reachable. // -// - live: the root is a tmpfs overlay. A previous run is gone after -// reboot, so stoat offers every time. -// - disk/cloud: packages persist. Stoat offers again only after a failed -// run, not a successful one. -func wantsAutoProvisionPrompt(v core.VM) bool { - if len(v.Recipes) == 0 { - return false - } - // cloud-init applies a cloud VM's recipes at first boot; there is nothing - // for ssh provisioning to do, and startProvision refuses it anyway. +// - A cloud VM never needs it: cloud-init already ran the recipes from the +// seed, before ssh was even reachable. +// - An uninstalled disk VM's sshd belongs to its own installer, running on +// a tmpfs root the install later replaces; reachability alone cannot +// tell that apart from the real system, so it is excluded up front. +// - A live VM's root is a tmpfs overlay: every reboot erases whatever a +// previous run installed, so core.NeedsProvision's Applied bookkeeping +// (which persists on the host, across reboots) cannot answer for it. Any +// recipe at all means there is work to redo. +// - Everything else defers to core.NeedsProvision. +func needsAutoProvision(v core.VM) bool { if v.Mode == "cloud" { return false } - // An uninstalled disk VM runs its own installer on a tmpfs root that the - // install later replaces. Its sshd may already answer, so this check - // cannot rely on reachability alone. - // Once installed, stoat offers only when the last run did not succeed. if v.Mode == "disk" && !v.Installed { return false } - if v.Mode != "live" && lastProvisionSucceeded(v) { - return false + if v.Mode == "live" { + return len(v.Recipes) > 0 } - return true + needs, err := core.NeedsProvision(toConfigVM(v)) + return err == nil && needs } -// lastProvisionSucceeded reports whether the VM's most recent provision run -// finished cleanly. sshx.Provision writes "done" as the final line, and -// truncates the file at the start of every run, so the tail is unambiguous. -func lastProvisionSucceeded(v core.VM) bool { - b := tailBytes(v.Paths.ApplyLog, provTailBytes) - if len(b) == 0 { - return false - } - lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n") - for i := len(lines) - 1; i >= 0; i-- { - if l := strings.TrimSpace(lines[i]); l != "" { - return l == "done" +// toConfigVM carries the fields core.NeedsProvision reads: the OS and +// backend the recipe run mode logic checks, and the Applied record it +// compares script hashes against. It is a narrower relative of app.go's +// cfgVM, built for this one call instead of sshx's identity fields. +func toConfigVM(v core.VM) *config.VM { + var applied map[string]config.AppliedRecipe + if len(v.Applied) > 0 { + applied = make(map[string]config.AppliedRecipe, len(v.Applied)) + for name, a := range v.Applied { + applied[name] = config.AppliedRecipe{Version: a.Version, Hash: a.Hash, At: a.At} } } - return false -} - -// autoProvisionPrompt is the y/N line shown when a started VM becomes -// reachable. It names the recipes so the answer is informed: "provision -// work?" says nothing about what is about to run. -func autoProvisionPrompt(v core.VM) string { - names := make([]string, len(v.Recipes)) - for i, r := range v.Recipes { - names[i] = recipeLabel(r) + return &config.VM{ + OS: v.OS, Mode: v.Mode, Share: v.Share, Recipes: v.Recipes, Applied: applied, } - return v.Name + " is up, run " + strings.Join(names, ", ") + " now? y/N" } // ensureNoStaleLog removes a provision log left by a previous boot of a live // VM. // // The log lives on the host and survives the reboot; nothing else does. -// Without removing it, lastProvisionSucceeded and the detail pane's tail -// both describe a run whose effects the reboot already wiped. +// Without removing it, the detail pane's tail describes a run whose effects +// the reboot already wiped. func ensureNoStaleLog(v core.VM) { if v.Mode != "live" { return diff --git a/internal/tui/autoprov_test.go b/internal/tui/autoprov_test.go index 0140a54..127ee61 100644 --- a/internal/tui/autoprov_test.go +++ b/internal/tui/autoprov_test.go @@ -1,38 +1,28 @@ package tui import ( - "os" "path/filepath" - "strings" "testing" "github.com/novusedge/stoat/internal/core" ) -func autoVM(t *testing.T, mode string, recipes []string, log string) core.VM { +func autoVM(t *testing.T, mode string, recipes []string) core.VM { t.Helper() dir := t.TempDir() - applyLog := filepath.Join(dir, "last-provision.log") - if log != "" { - if err := os.WriteFile(applyLog, []byte(log), 0o644); err != nil { - t.Fatal(err) - } - } return core.VM{ Name: "vm", Mode: mode, OS: "alpine", Installed: true, SSHPort: 2200, Recipes: recipes, State: core.StateRunning, - Paths: core.Paths{Dir: dir, ApplyLog: applyLog}, + Paths: core.Paths{Dir: dir, ApplyLog: filepath.Join(dir, "last-provision.log")}, } } -// TestWantsAutoProvisionPrompt covers who gets offered and who doesn't. The -// live-vs-disk difference is not a preference: a live VM's root is a tmpfs -// overlay, so a previous run is genuinely gone after the reboot, while a disk -// VM's packages are still there. -func TestWantsAutoProvisionPrompt(t *testing.T) { - const ok = "=== recipe xfce.alpine.sh ===\nOK: 378 packages\n\ndone\n" - const failed = "waiting for ssh on port 2200…\nFAILED: vm: ssh not reachable\n" +// TestNeedsAutoProvision covers who gets provisioned automatically and who +// doesn't. The live-vs-disk difference is not a preference: a live VM's root +// is a tmpfs overlay, so a previous run is genuinely gone after the reboot, +// while a disk VM's packages persist and its Applied record can be trusted. +func TestNeedsAutoProvision(t *testing.T) { recipes := []string{"xfce.alpine.sh"} cases := []struct { @@ -40,119 +30,93 @@ func TestWantsAutoProvisionPrompt(t *testing.T) { vm core.VM want bool }{ - {"live, never provisioned", autoVM(t, "live", recipes, ""), true}, - {"live, previously succeeded, the reboot wiped it", autoVM(t, "live", recipes, ok), true}, - {"disk, never provisioned", autoVM(t, "disk", recipes, ""), true}, - {"disk, previously succeeded, still installed", autoVM(t, "disk", recipes, ok), false}, - {"disk, previous run failed", autoVM(t, "disk", recipes, failed), true}, - {"no recipes", autoVM(t, "live", nil, ""), false}, - {"cloud, cloud-init already did it", autoVM(t, "cloud", recipes, ""), false}, + {"live, never provisioned", autoVM(t, "live", recipes), true}, + {"live, no recipes", autoVM(t, "live", nil), false}, + {"disk, never provisioned", autoVM(t, "disk", recipes), true}, + {"disk, no recipes, no share", autoVM(t, "disk", nil), false}, + {"cloud, cloud-init already did it", autoVM(t, "cloud", recipes), false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - if got := wantsAutoProvisionPrompt(c.vm); got != c.want { + if got := needsAutoProvision(c.vm); got != c.want { t.Errorf("got %v, want %v", got, c.want) } }) } - // A disk VM with no OS installed never becomes reachable at all. - uninstalled := autoVM(t, "disk", recipes, "") + // A disk VM with no OS installed never becomes reachable as itself; its + // sshd, if any, belongs to the installer. + uninstalled := autoVM(t, "disk", recipes) uninstalled.Installed = false - if wantsAutoProvisionPrompt(uninstalled) { - t.Error("offered to provision a disk VM with no OS installed") + if needsAutoProvision(uninstalled) { + t.Error("wanted to auto-provision a disk VM with no OS installed") } -} -func TestLastProvisionSucceeded(t *testing.T) { - cases := []struct { - name, log string - want bool - }{ - {"clean run", "=== recipe x ===\nOK\n\ndone\n", true}, - {"trailing blank lines", "done\n\n\n", true}, - {"failed", "FAILED: recipe x: exit 1\n", false}, - {"still running", "=== recipe x ===\n(3/9) Installing foo\n", false}, - {"no log at all", "", false}, - {"the word done inside output", "installing done-stuff\n", false}, + // A live VM's tmpfs root wipes every reboot. Its Applied record, saved on + // the host, survives, but it describes a filesystem that is gone; a live + // VM auto-applies every boot regardless of what Applied says. + live := autoVM(t, "live", recipes) + live.Applied = map[string]core.AppliedRecipe{"xfce.alpine.sh": {Version: "1.0", Hash: "whatever"}} + if !needsAutoProvision(live) { + t.Error("a live VM must auto-provision every boot even with a stale Applied record") } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := lastProvisionSucceeded(autoVM(t, "disk", nil, c.log)); got != c.want { - t.Errorf("got %v, want %v", got, c.want) - } - }) + + // A disk VM with a share still needs the idempotent share-mount step, + // even once every recipe is applied. + shared := autoVM(t, "disk", nil) + shared.Share = "/host/path" + if !needsAutoProvision(shared) { + t.Error("a disk VM with a share must still auto-provision for the mount step") } } -// TestAutoProvisionPromptNamesTheRecipes: "provision vm?" says nothing about -// what is about to run inside the guest, which is the whole point of asking. -func TestAutoProvisionPromptNamesTheRecipes(t *testing.T) { - v := autoVM(t, "live", []string{"xfce.alpine.sh", "docker.alpine.sh"}, "") - p := autoProvisionPrompt(v) - for _, want := range []string{"vm", "xfce", "docker", "y/N"} { - if !strings.Contains(p, want) { - t.Errorf("prompt %q is missing %q", p, want) - } +// TestSSHReadyAutoProvisions is the sshReadyMsg handler's main path: it +// starts a provision run itself, with no confirmation state in between. +func TestSSHReadyAutoProvisions(t *testing.T) { + v := autoVM(t, "live", []string{"xfce.alpine.sh"}) + m := model{screen: screenList, list: newVMList(), spin: newSpinner(), + provisioning: map[string]provState{}, vms: []core.VM{v}} + + out, cmd := m.Update(sshReadyMsg{name: v.Name}) + after := out.(model) + + if _, running := after.provisioning[v.Name]; !running { + t.Error("sshReadyMsg did not start a provision run") } - // Filenames are an implementation detail the user never chose. - if strings.Contains(p, ".alpine.sh") { - t.Errorf("prompt shows raw filenames: %q", p) + if cmd == nil { + t.Error("sshReadyMsg returned no command") } -} - -// TestDeclinedOfferDoesNotOpenTheNewVMForm is the collision that makes a y/N -// prompt dangerous here: "n" is bound to new-VM, so declining with the obvious -// key would have opened the create form. -func TestDeclinedOfferDoesNotOpenTheNewVMForm(t *testing.T) { - v := autoVM(t, "live", []string{"xfce.alpine.sh"}, "") - for _, key := range []string{"n", "N", "esc", "q"} { - m := model{screen: screenList, list: newVMList(), spin: newSpinner(), - provisioning: map[string]provState{}, pendingProvision: &v} - - out, _ := m.updateList(keyMsg(key)) - after := out.(model) - - if after.screen != screenList { - t.Errorf("declining with %q left screen %v", key, after.screen) - } - if after.pendingProvision != nil { - t.Errorf("declining with %q left the offer pending", key) - } - if len(after.provisioning) != 0 { - t.Errorf("declining with %q started provisioning anyway", key) - } - if !strings.Contains(after.toast.text, "press p") { - t.Errorf("declining with %q gave no way back in: %q", key, after.toast.text) - } + if after.status != "" { + t.Errorf("status = %q, want no prompt", after.status) } } -// TestAcceptedOfferProvisions is the other half. -func TestAcceptedOfferProvisions(t *testing.T) { - v := autoVM(t, "live", []string{"xfce.alpine.sh"}, "") +// TestSSHReadyDoesNothingWhenNothingToProvision: a VM with no recipes and no +// share must not start a run, and must show nothing. +func TestSSHReadyDoesNothingWhenNothingToProvision(t *testing.T) { + v := autoVM(t, "disk", nil) m := model{screen: screenList, list: newVMList(), spin: newSpinner(), - provisioning: map[string]provState{}, pendingProvision: &v} + provisioning: map[string]provState{}, vms: []core.VM{v}} - out, cmd := m.updateList(keyMsg("y")) + out, cmd := m.Update(sshReadyMsg{name: v.Name}) after := out.(model) - if after.pendingProvision != nil { - t.Error("accepting left the offer pending") + if len(after.provisioning) != 0 { + t.Error("started a provision run with nothing to provision") } - if _, running := after.provisioning[v.Name]; !running { - t.Error("accepting did not start a provision run") + if cmd != nil { + t.Error("returned a command with nothing to provision") } - if cmd == nil { - t.Error("accepting returned no command") + if after.status != "" { + t.Errorf("status = %q, want nothing shown", after.status) } } -// TestOfferNeverPreemptsADeletePrompt: a pending delete is a more -// consequential question and the user is mid-answer; an offer arriving on a -// 90-second timer must not overwrite it. -func TestOfferNeverPreemptsADeletePrompt(t *testing.T) { - v := autoVM(t, "live", []string{"xfce.alpine.sh"}, "") +// 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. +func TestSSHReadyNeverPreemptsADeletePrompt(t *testing.T) { + v := autoVM(t, "live", []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"} @@ -160,8 +124,8 @@ func TestOfferNeverPreemptsADeletePrompt(t *testing.T) { out, _ := m.Update(sshReadyMsg{name: v.Name}) after := out.(model) - if after.pendingProvision != nil { - t.Error("the provision offer overwrote a pending delete confirmation") + if len(after.provisioning) != 0 { + t.Error("auto-provision 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) diff --git a/internal/tui/list.go b/internal/tui/list.go index 1575cf9..a682beb 100644 --- a/internal/tui/list.go +++ b/internal/tui/list.go @@ -75,19 +75,6 @@ func (m model) updateList(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } - // The auto-provision offer owns all keys while pending, for the same - // reason the delete prompt below does: "n" is bound to new-VM, so a - // plain key switch would open the form on a decline. - if m.pendingProvision != nil { - v := *m.pendingProvision - m.pendingProvision = nil - if key.String() == "y" { - return m, m.startProvision(v) - } - cmd := m.showToast("not provisioning "+v.Name+", press p when you want to", false) - return m, cmd - } - // The delete confirmation prompt owns all keys while pending: "y" // confirms, anything else cancels. This must run before the normal // switch below because "n" is otherwise bound to "new VM". diff --git a/internal/tui/snapshots.go b/internal/tui/snapshots.go index ad92471..750481f 100644 --- a/internal/tui/snapshots.go +++ b/internal/tui/snapshots.go @@ -36,14 +36,13 @@ type snapshotsModal struct { // pendingDelete and pendingRestore hold the snapshot awaiting a y/N // answer. "y" confirms, any other key cancels, the same rule as - // list.go's pendingDelete and app.go's pendingProvision. + // list.go's pendingDelete. // // Restore discards everything since the snapshot was taken, with no way // back. That makes it as destructive as delete, so it gets the same gate. // // Two fields, not one shared field with a "which action" tag. Only one is - // ever armed at a time. Mirrors app.go's own pendingDelete/pendingProvision - // split. + // ever armed at a time. pendingDelete *core.Snapshot pendingRestore *core.Snapshot From b0f32405fbe31f242d7d88e20ce4c1481c68446b Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Mon, 10 Aug 2026 00:26:42 +0300 Subject: [PATCH 3/3] feat(cli): auto-provision after stoat up, add --no-provision afterStart waits for ssh and runs core.Apply once a started VM answers, the same path stoat provision uses, streaming its log the same way runApply does. It runs only when core.NeedsProvision reports real work pending, so a VM with nothing to do returns immediately. --no-provision skips this and returns as soon as the VM starts, matching up's behavior before this feature existed. --- internal/cli/cli.go | 5 +++ internal/cli/cli_test.go | 1 + internal/cli/grammar.go | 9 +++- internal/cli/run_vm.go | 47 ++++++++++++++++++++- internal/cli/run_vm_test.go | 70 ++++++++++++++++++++++++++++++++ internal/core/needs_provision.go | 3 +- 6 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 internal/cli/run_vm_test.go diff --git a/internal/cli/cli.go b/internal/cli/cli.go index a5edb6b..47d58ce 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -46,6 +46,11 @@ type Args struct { Yes bool N int // logs -n + // NoProvision belongs to "up": it skips the automatic post-boot + // provision, leaving `up` returning as soon as the VM starts, as it did + // before that behavior existed. + NoProvision bool + // JSON is set by Main from the pre-parse argv scan, never by Parse: the // flag has to be recognized before any parser exists so a usage error // can still produce an envelope. It implies Quiet, so every prose line diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index d04ef5b..8b1e315 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -73,6 +73,7 @@ func TestParse(t *testing.T) { {"up missing name", []string{"up"}, nil, true}, {"up too many args", []string{"up", "a", "b"}, nil, true}, {"up quiet", []string{"up", "-q", "alpine"}, &Args{Cmd: "up", VM: "alpine", Quiet: true}, false}, + {"up --no-provision", []string{"up", "--no-provision", "alpine"}, &Args{Cmd: "up", VM: "alpine", NoProvision: true}, false}, {"down", []string{"down", "alpine"}, &Args{Cmd: "down", VM: "alpine"}, false}, {"down missing name", []string{"down"}, nil, true}, diff --git a/internal/cli/grammar.go b/internal/cli/grammar.go index 229d14b..77fffc7 100644 --- a/internal/cli/grammar.go +++ b/internal/cli/grammar.go @@ -75,7 +75,8 @@ type getCmd struct { } type upCmd struct { - VM string `arg:"" help:"vm name"` + VM string `arg:"" help:"vm name"` + NoProvision bool `name:"no-provision" help:"start only; skip the automatic post-boot provision"` } type downCmd struct { @@ -248,9 +249,13 @@ func (g *grammar) toArgs(path string) (*Args, error) { // FLAG path gets it from kong's own buffer. a.Help = helpText() - case "get", "up", "down", "ssh", "ssh-command", "provision": + case "get", "down", "ssh", "ssh-command", "provision": a.VM = g.vmFor(path) + case "up": + a.VM = g.Up.VM + a.NoProvision = g.Up.NoProvision + case "rm": a.VM, a.Yes = g.RM.VM, g.RM.Yes diff --git a/internal/cli/run_vm.go b/internal/cli/run_vm.go index 3713810..8a3acac 100644 --- a/internal/cli/run_vm.go +++ b/internal/cli/run_vm.go @@ -2,13 +2,16 @@ package cli import ( "bufio" + "context" "errors" "fmt" "io" "strings" "github.com/novusedge/stoat/internal/cli/wire" + "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/core" + "github.com/novusedge/stoat/internal/sshx" ) func runLS(a *Args, stdout, stderr io.Writer) int { @@ -86,8 +89,50 @@ func runUp(a *Args, stdout, stderr io.Writer) int { // installed, and that flip is exactly what moves the screen off the qemu // window. The pre-Start copy would announce a window that is not there. if started, err := core.Get(a.VM); err == nil { - printDisplay(stdout, core.DisplayFor(started, core.GraphicalSession())) + v = started } + printDisplay(stdout, core.DisplayFor(v, core.GraphicalSession())) + return afterStart(a, v, stdout, stderr) +} + +// afterStart runs v's pending recipes once it answers ssh, the same +// core.Apply path `stoat provision` uses (run_access.go's runProvision). +// --no-provision, or a VM with nothing pending, returns immediately: `up` +// does not block a VM that has no work waiting. +func afterStart(a *Args, v core.VM, stdout, stderr io.Writer) int { + if a.NoProvision { + return ExitOK + } + cfg, err := config.Load(v.Name) + if err != nil { + return a.fail(stdout, stderr, err) + } + needs, err := core.NeedsProvision(cfg) + if err != nil { + return a.fail(stdout, stderr, err) + } + if !needs { + return ExitOK + } + + if !a.Quiet { + fmt.Fprintf(stdout, "waiting for ssh on %s...\n", a.VM) + } + ctx, cancel := context.WithTimeout(context.Background(), sshx.WaitTimeout) + defer cancel() + if err := core.Wait(ctx, a.VM, core.UntilReachable); err != nil { + return a.fail(stdout, stderr, err) + } + + if !a.Quiet { + fmt.Fprintf(stdout, "provisioning %s...\n", a.VM) + } + done := make(chan error, 1) + go func() { done <- core.Apply(context.Background(), a.VM, core.ApplyOpts{}) }() + if err := streamFile(v.Paths.ApplyLog, stdout, done); err != nil { + return a.fail(stdout, stderr, err) + } + fmt.Fprintf(stdout, "%s provisioned\n", a.VM) return ExitOK } diff --git a/internal/cli/run_vm_test.go b/internal/cli/run_vm_test.go new file mode 100644 index 0000000..df96c5b --- /dev/null +++ b/internal/cli/run_vm_test.go @@ -0,0 +1,70 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/core" +) + +// afterStart is exercised directly rather than through runUp, which would +// need core.Start to actually launch qemu. fakeRunning marks the VM as +// running the same way apply_test.go does, so afterStart's own decision +// (core.NeedsProvision, --no-provision) is tested without a real boot or a +// real ssh wait. +func startedVM(t *testing.T, name string, patch func(*config.VM)) core.VM { + t.Helper() + dir := cliRoot(t) + v := &config.VM{Name: name, Mode: "live", OS: "alpine", RAM: 512, CPUs: 1, SSHPort: 2200} + if patch != nil { + patch(v) + } + saveVM(t, v) + v.Dir = filepath.Join(dir, name) + t.Cleanup(fakeRunning(t, v)) + + cv, err := core.Get(name) + if err != nil { + t.Fatal(err) + } + return cv +} + +// TestAfterStartSkipsWhenNothingPending: a VM with no recipes and no share +// has nothing for a provision run to do, so `up` must return without +// waiting for ssh. +func TestAfterStartSkipsWhenNothingPending(t *testing.T) { + v := startedVM(t, "work", nil) + + var out, errOut strings.Builder + a := &Args{Cmd: "up", VM: "work"} + code := afterStart(a, v, &out, &errOut) + + if code != ExitOK { + t.Fatalf("code = %d, want ExitOK: %s", code, errOut.String()) + } + if strings.Contains(out.String(), "provisioning") || strings.Contains(out.String(), "waiting for ssh") { + t.Errorf("waited on a VM with nothing pending: %q", out.String()) + } +} + +// TestAfterStartNoProvisionSkipsEvenWithRecipesPending: --no-provision must +// win over core.NeedsProvision, not just apply when there is nothing to do. +func TestAfterStartNoProvisionSkipsEvenWithRecipesPending(t *testing.T) { + v := startedVM(t, "work", func(v *config.VM) { + v.Recipes = []string{"xfce.alpine.sh"} + }) + + var out, errOut strings.Builder + a := &Args{Cmd: "up", VM: "work", NoProvision: true} + code := afterStart(a, v, &out, &errOut) + + if code != ExitOK { + t.Fatalf("code = %d, want ExitOK: %s", code, errOut.String()) + } + if strings.Contains(out.String(), "provisioning") || strings.Contains(out.String(), "waiting for ssh") { + t.Errorf("--no-provision did not skip provisioning: %q", out.String()) + } +} diff --git a/internal/core/needs_provision.go b/internal/core/needs_provision.go index c631331..a018205 100644 --- a/internal/core/needs_provision.go +++ b/internal/core/needs_provision.go @@ -10,8 +10,7 @@ import "github.com/novusedge/stoat/internal/config" // idempotent but leaves no Applied entry to check. // // A cloud VM always returns false. cloud-init applies its recipes from the -// seed at first boot, matching the guard autoprov.go's -// wantsAutoProvisionPrompt uses. +// seed at first boot, so an ssh provision run has nothing to do. func NeedsProvision(v *config.VM) (bool, error) { if v.Mode == "cloud" { return false, nil