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
5 changes: 5 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
9 changes: 7 additions & 2 deletions internal/cli/grammar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand Down
47 changes: 46 additions & 1 deletion internal/cli/run_vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
70 changes: 70 additions & 0 deletions internal/cli/run_vm_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
26 changes: 26 additions & 0 deletions internal/core/needs_provision.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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, so an ssh provision run has nothing to do.
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
}
105 changes: 105 additions & 0 deletions internal/core/needs_provision_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
19 changes: 8 additions & 11 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading