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
15 changes: 8 additions & 7 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,26 +99,26 @@ session exists but QEMU cannot draw on it.

(`internal/sshx/sshx.go`'s `Wait`, with `WaitTimeout` set to 90 seconds.)

**If this happens while provisioning a disk VM:** the VM is still installing
itself off the ISO. Its guest OS is not on the disk yet, so there is nothing to
provision, and `sshd` answering means the *installer's* sshd is up, not the
system you are building.
**If this happens while applying recipes to a disk VM:** the VM is still
installing itself off the ISO. Its guest OS is not on the disk yet, so there
are no recipes to apply, and `sshd` answering means the *installer's* sshd is
up, not the system you are building.

**Fix:** wait. The unattended `setup-alpine` finishes, reboots into the disk,
and the next start notices the OS, marks the VM installed and drops the ISO from
the boot order. Pressing `p` before that is refused outright, rather than making
you wait out the full timeout to find out:

```
<name>: installing itself; wait for it to finish and reboot, then stoat notices the install and offers to provision
<name>: installing itself; wait for it to finish and reboot, then stoat notices the install and offers to apply recipes
```
Comment on lines 112 to 114

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Specify a language for the fenced block.

Add a language tag to the status example so Markdown lint passes and renderers treat it as literal text.

Proposed fix
-```
+```text
 <name>: installing itself; wait for it to finish and reboot, then stoat notices the install and offers to apply recipes
</details>





</review_comment>
</file_review>

<file_review path="internal/tui/app.go">


<review_comment line_ranges="361-363">
**Use a complete concurrent-apply message.**

`recipes already applying` is grammatically incomplete. Use `recipes are already being applied` or match the CLI wording, `an apply is already running`.

<details>
<summary>Proposed fix</summary>

```diff
-			cmd := m.showToast(msg.name+": recipes already applying", false)
+			cmd := m.showToast(msg.name+": recipes are already being applied", false)

</review_comment>

<review_comment line_ranges="368-368">
LGTM!

</review_comment>

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
<name>: installing itself; wait for it to finish and reboot, then stoat notices the install and offers to provision
<name>: installing itself; wait for it to finish and reboot, then stoat notices the install and offers to apply recipes
```
Suggested change
```
<name>: installing itself; wait for it to finish and reboot, then stoat notices the install and offers to provision
<name>: installing itself; wait for it to finish and reboot, then stoat notices the install and offers to apply recipes
```
cmd := m.showToast(msg.name+": recipes are already being applied", false)
🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 112-112: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/troubleshooting.md` around lines 112 - 114, Tag the troubleshooting
status example’s fenced Markdown block as text, and update the concurrent-apply
message in the TUI app to use complete wording such as “recipes are already
being applied” or the CLI-compatible “an apply is already running.”

Source: Linters/SAST tools


`i` on the detail screen still toggles `installed` by hand, for when that
guess goes wrong in either direction: an install that died halfway leaves
enough bytes on the disk to look finished (the threshold is `installedBytes`
in `internal/qemu/run.go`), and `i` is how you get the ISO back.

## Provisioning a disk VM fails with `Permission denied (publickey,...)`
## Applying recipes to a disk VM fails with `Permission denied (publickey,...)`

An Alpine disk VM gets the same apkovl a live one does
(`internal/apkovl/apkovl.go`) while it is still uninstalled, precisely so
Expand Down Expand Up @@ -208,7 +208,8 @@ machine) or a **cloud** VM (a prebuilt image where cloud-init's `packages:`/
A VM's directory exists under the data root but its `vm.toml` doesn't parse
(`internal/config/config.go`'s `ListBroken`). stoat still shows it, rather
than hiding a directory it can't fully understand, so you know it's there and
can act on it, but it can't be started, edited, or provisioned in that state.
can act on it, but it can't be started, edited, or have recipes applied in
that state.
Its reserved ssh port stays held (`FreePort` checks broken VMs' raw `vm.toml`
port fields too), so a broken VM won't silently let a new VM reuse its port
out from under it.
Expand Down
10 changes: 5 additions & 5 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ 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
// NoApply belongs to "up": it skips the automatic post-boot apply,
// leaving `up` returning as soon as the VM starts, as it did before that
// behavior existed.
NoApply 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
Expand Down Expand Up @@ -392,7 +392,7 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ
case "ssh":
return runSSH(a, stdout, stderr)
case "provision":
return runProvision(a, stdout, stderr)
return runApply(a, stdout, stderr)
case "rm":
return runRM(a, stdin, stdout, stderr)
case "recipe":
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ 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},
{"up --no-apply", []string{"up", "--no-apply", "alpine"}, &Args{Cmd: "up", VM: "alpine", NoApply: true}, false},
{"up --no-provision (hidden alias)", []string{"up", "--no-provision", "alpine"}, &Args{Cmd: "up", VM: "alpine", NoApply: true}, false},

{"down", []string{"down", "alpine"}, &Args{Cmd: "down", VM: "alpine"}, false},
{"down missing name", []string{"down"}, nil, true},
Expand Down
19 changes: 8 additions & 11 deletions internal/cli/grammar.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ type grammar struct {
Prune pruneCmd `cmd:"" help:"report, or with --apply remove, stale files"`

Apply applyCmd `cmd:"" help:"run the VM's recipes, streaming output"`
Provision provisionCmd `cmd:"" help:"run recipes, streaming output to stdout"`
Provision applyCmd `cmd:"" hidden:"" name:"provision" help:"alias of apply"`
Recipes recipesCmd `cmd:"" help:"list recipes, optionally only applicable ones"`
CheckRecipes checkRecipesCmd `cmd:"" help:"report why a recipe would not apply"`
Recipe recipeCmd `cmd:"" help:"author recipes"`
Expand All @@ -75,8 +75,8 @@ type getCmd struct {
}

type upCmd struct {
VM string `arg:"" help:"vm name"`
NoProvision bool `name:"no-provision" help:"start only; skip the automatic post-boot provision"`
VM string `arg:"" help:"vm name"`
NoApply bool `name:"no-apply" aliases:"no-provision" help:"start only; skip the automatic post-boot apply"`
}

type downCmd struct {
Expand All @@ -91,10 +91,6 @@ type sshCmdCmd struct {
VM string `arg:"" help:"vm name"`
}

type provisionCmd struct {
VM string `arg:"" help:"vm name"`
}

type rmCmd struct {
VM string `arg:"" help:"vm name"`
Yes bool `short:"y" help:"skip the delete confirmation"`
Expand Down Expand Up @@ -249,12 +245,12 @@ func (g *grammar) toArgs(path string) (*Args, error) {
// FLAG path gets it from kong's own buffer.
a.Help = helpText()

case "get", "down", "ssh", "ssh-command", "provision":
case "get", "down", "ssh", "ssh-command":
a.VM = g.vmFor(path)

case "up":
a.VM = g.Up.VM
a.NoProvision = g.Up.NoProvision
a.NoApply = g.Up.NoApply

case "rm":
a.VM, a.Yes = g.RM.VM, g.RM.Yes
Expand Down Expand Up @@ -389,6 +385,9 @@ func (g *grammar) toArgs(path string) (*Args, error) {
case "apply":
a.VM, a.Only = g.Apply.VM, trimList(g.Apply.Only)

case "provision":
a.VM, a.Only = g.Provision.VM, trimList(g.Provision.Only)

case "recipes":
a.OS, a.Backend = g.Recipes.OS, g.Recipes.Backend

Expand Down Expand Up @@ -480,8 +479,6 @@ func (g *grammar) vmFor(path string) string {
return g.SSH.VM
case "ssh-command":
return g.SSHCmd.VM
case "provision":
return g.Provision.VM
}
return ""
}
32 changes: 30 additions & 2 deletions internal/cli/kong_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@ func TestTrimListAcrossCommands(t *testing.T) {
t.Errorf("apply --only with trailing comma = %v, want %v", a.Only, want)
}

// provision is a hidden alias of apply and takes the same --only flag.
a, err = Parse([]string{"provision", "work", "--only", "a.sh, b.sh"})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(a.Only, want) {
t.Errorf("provision --only = %v, want %v", a.Only, want)
}

// check-recipes' Names is a positional []string (arg:""). Kong's Sep tag
// only splits a FLAG's value, never a positional, so kong alone hands
// "a.sh,b.sh" back as one element containing a comma. trimList splits
Expand Down Expand Up @@ -262,12 +271,14 @@ func TestSnapshotXor(t *testing.T) {
// small spot-check to the full command surface: every leaf command kong
// generates a line for must be named here, so adding a subcommand to
// grammar.go without it appearing in the generated help fails this build
// rather than silently shipping an undocumented command.
// rather than silently shipping an undocumented command. "provision" is
// deliberately absent: it is a hidden alias of "apply", so kong omits it
// from the generated help by design (see TestProvisionIsHiddenFromHelp).
func TestHelpListsEverySubcommand(t *testing.T) {
want := []string{
"ls", "get", "create", "update", "up", "down", "wait", "rm", "clone",
"exec", "ssh", "ssh-command", "cp", "forward", "images", "pull",
"snapshot", "prune", "apply", "provision", "recipes", "check-recipes",
"snapshot", "prune", "apply", "recipes", "check-recipes",
"recipe list", "recipe new", "logs", "doctor", "version", "help",
}
for _, args := range [][]string{{"help"}, {"--help"}, {"-h"}} {
Expand All @@ -282,3 +293,20 @@ func TestHelpListsEverySubcommand(t *testing.T) {
}
}
}

// TestProvisionIsHiddenFromHelp pins that the "provision" alias still parses
// (TestParse's "provision" cases) while staying out of the command list a
// user reads with --help, so the help surface names only the one spelling
// ("apply") a new user should reach for.
func TestProvisionIsHiddenFromHelp(t *testing.T) {
a, err := Parse([]string{"--help"})
if err != nil {
t.Fatal(err)
}
for _, line := range strings.Split(a.Help, "\n") {
fields := strings.Fields(line)
if len(fields) > 0 && fields[0] == "provision" {
t.Errorf("help lists the hidden provision alias: %q", line)
}
}
}
80 changes: 0 additions & 80 deletions internal/cli/run_access.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@ package cli
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
Expand Down Expand Up @@ -121,84 +119,6 @@ func runSSH(a *Args, stdout, stderr io.Writer) int {
return ExitOK // unreachable on success: the process image is gone
}

// runProvision runs sshx.Provision (which does the actual work and writes
// last-provision.log) in the background while polling that same file and
// copying new bytes to stdout, so the CLI shows live output without any
// duplicated provisioning logic.
func runProvision(a *Args, stdout, stderr io.Writer) int {
v, err := config.Load(a.VM)
if err != nil {
return a.fail(stdout, stderr, err)
}
if v.Mode == "cloud" {
// cloud-init's packages: list runs only at first boot, baked into
// the seed when the overlay was created. There is nothing left for
// ssh-based provisioning to do, and a cloud recipe is #cloud-config
// YAML, not a shell script, so piping it into `sh -s` would fail.
const reason = "cloud VM: recipes are applied by cloud-init at first boot; recreate the VM to change them"
if a.JSON {
// A consumer has to be able to tell "recipes ran" from "there was
// nothing to run" without reading English prose.
return a.ok(stdout, map[string]any{
"vm": a.VM, "provisioned": false, "skipped_reason": reason,
})
}
fmt.Fprintf(stdout, "%s is a cloud VM: recipes are applied automatically via cloud-init at first boot; recreate the VM to change them.\n", a.VM)
return ExitOK
}
if !a.Quiet {
fmt.Fprintf(stdout, "provisioning %s...\n", a.VM)
}

// No cancellation source reaches here yet: runProvision has no signal
// handling of its own, so this is a call site noted for the caller to
// decide whether Ctrl-C should cancel an in-flight provision, not a
// design decision made here.
logPath := filepath.Join(v.Dir, "last-provision.log")
done := make(chan error, 1)
// core.WithProvisionLock, not core.Apply: this path drives sshx.Provision
// directly (its cloud-VM handling and JSON shape differ from Apply's), so
// it takes the same per-VM lock itself instead of going through Apply.
go func() {
done <- core.WithProvisionLock(v.Dir, func() error {
return sshx.Provision(context.Background(), v)
})
}()

// Under --json the recipe's raw bytes must not reach stdout: they would sit
// in the middle of the JSON Lines stream and every consumer's json.loads
// would fail on them. Each appended line becomes a "log" event instead.
out := stdout
var lw *jsonLogWriter
if a.JSON {
lw = &jsonLogWriter{em: wire.NewEmitter(stdout), cmd: a.Cmd}
out = lw
}
perr := streamFile(logPath, out, done)
if lw != nil {
lw.Flush()
}
if errors.Is(perr, core.ErrProvisionInProgress) {
// Another run already holds the VM's provision lock; that run owns
// the error, so this one exits clean instead of reporting it too.
if a.JSON {
return a.ok(stdout, map[string]any{"vm": a.VM, "provisioned": false, "skipped_reason": "provision already running"})
}
fmt.Fprintf(stdout, "%s: provision already running\n", a.VM)
return ExitOK
}
if perr != nil {
return a.fail(stdout, stderr, perr)
}
if a.JSON {
return a.ok(stdout, map[string]any{
"vm": a.VM, "provisioned": true, "skipped_reason": "",
})
}
fmt.Fprintf(stdout, "%s provisioned\n", a.VM)
return ExitOK
}

// jsonLogWriter wraps appended log bytes as one "log" event per line.
type jsonLogWriter struct {
em *wire.Emitter
Expand Down
12 changes: 6 additions & 6 deletions internal/cli/run_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import (
"github.com/novusedge/stoat/internal/core"
)

// runApply runs a VM's recipes and streams their output live, the same
// pattern as runProvision (run_access.go): the work happens in a goroutine
// while the apply log file is tailed and copied out as it grows.
// runApply runs a VM's recipes and streams their output live: the work
// happens in a goroutine while the apply log file is tailed and copied out
// as it grows. It also serves the "provision" alias (cli.go's dispatch).
//
// core.Get, not config.Load: the load is only here for the log path to tail
// and the VM's recipe list, but config.Load returns an untyped error, so a
Expand All @@ -38,7 +38,7 @@ func runApply(a *Args, stdout, stderr io.Writer) int {

// Under --json, raw log bytes must not reach stdout: they would sit
// inside the JSON Lines stream and break every consumer's parse. Each
// appended line becomes a "log" event instead, same as runProvision.
// appended line becomes a "log" event instead.
out := stdout
var lw *jsonLogWriter
if a.JSON {
Expand All @@ -54,9 +54,9 @@ func runApply(a *Args, stdout, stderr io.Writer) int {
// caller owns the error; this one exits clean rather than reporting
// somebody else's concurrent apply as its own failure.
if a.JSON {
return a.ok(stdout, map[string]any{"vm": a.VM, "applied": false, "skipped_reason": "provision already running"})
return a.ok(stdout, map[string]any{"vm": a.VM, "applied": false, "skipped_reason": "an apply is already running"})
}
fmt.Fprintf(stdout, "%s: provision already running\n", a.VM)
fmt.Fprintf(stdout, "%s: an apply is already running\n", a.VM)
return ExitOK
}
if aerr != nil {
Expand Down
18 changes: 9 additions & 9 deletions internal/cli/run_vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,11 @@ func runUp(a *Args, stdout, stderr io.Writer) int {
}

// 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`
// core.Apply path `stoat apply` uses (run_apply.go's runApply).
// --no-apply, 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 {
if a.NoApply {
return ExitOK
}
cfg, err := config.Load(v.Name)
Expand All @@ -125,21 +125,21 @@ func afterStart(a *Args, v core.VM, stdout, stderr io.Writer) int {
}

if !a.Quiet {
fmt.Fprintf(stdout, "provisioning %s...\n", a.VM)
fmt.Fprintf(stdout, "applying recipes to %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 {
if errors.Is(err, core.ErrProvisionInProgress) {
// A concurrent `apply` or `provision` already holds the lock; that
// run owns the error. `up` still started the VM, so this is not a
// failure of the up command.
fmt.Fprintf(stdout, "%s: provision already running\n", a.VM)
// A concurrent `apply` already holds the lock; that run owns the
// error. `up` still started the VM, so this is not a failure of
// the up command.
fmt.Fprintf(stdout, "%s: an apply is already running\n", a.VM)
return ExitOK
}
return a.fail(stdout, stderr, err)
}
fmt.Fprintf(stdout, "%s provisioned\n", a.VM)
fmt.Fprintf(stdout, "%s: recipes applied\n", a.VM)
return ExitOK
}

Expand Down
Loading
Loading