diff --git a/docs/qa/tui-clickthrough.md b/docs/qa/tui-clickthrough.md
new file mode 100644
index 0000000..c79fb8d
--- /dev/null
+++ b/docs/qa/tui-clickthrough.md
@@ -0,0 +1,79 @@
+# TUI click-through: manual smoke test
+
+The checks an agent cannot run, because they need a real Alpine boot and a human
+reading the screen. Run these after any change to the list or detail screens.
+
+Data root is `~/.stoat` (or `$STOAT_HOME`). Each VM is a directory
+`~/.stoat/
/` that holds a `vm.toml`. Build and launch the TUI with `just run`
+or `go run ./cmd/stoat`.
+
+Keys, for reference. List screen: `enter` start/stop, `l`/`→` details, `s` ssh,
+`p` provision, `d` delete, `n` new, `r` edit recipes, `/` search. Detail screen:
+`e` edit form, `E` raw vm.toml in `$EDITOR`, `i` installed toggle, `s` ssh, `p`
+provision, `L` console log, `S` snapshots, `esc`/`h`/`q` back.
+
+## 1. VM start: row flip and uptime tick
+
+Verifies the running dot and the `time.Since(StartedAt)` uptime that ticks with
+no list refresh.
+
+1. On the list, select a stopped VM. The dot is the stopped glyph, the row shows
+ `-`.
+2. Press `enter`. The VM starts.
+3. Watch the row. The dot flips to the running glyph. The row reads
+ `up 1s :PORT`, then `up 2s`, then `up 3s`. The count climbs every second on
+ its own.
+4. Confirm the seconds keep climbing without any keypress. A frozen number means
+ the uptime regressed to a stored duration.
+5. Press `enter` again to stop it. The dot returns to stopped and the uptime
+ drops to `-`.
+
+## 2. Corrupt vm.toml: shows broken, deletes with d+y
+
+Verifies a broken VM still lists and deletes, keyed by its directory.
+
+1. Stop the TUI. Pick a directory name that sorts mid-list, e.g. `mmm-broken`.
+ Create `~/.stoat/mmm-broken/vm.toml` with garbage: `not = valid = toml`.
+2. Launch the TUI. `mmm-broken` appears in alphabetical position, marked broken.
+3. Select it and press `l`. The detail screen still opens; `L` serves any
+ console log the directory holds.
+4. Back on the list, press `d`. A `y/N` confirmation appears.
+5. Press `y`. The row is removed and `~/.stoat/mmm-broken/` is gone.
+6. Repeat step 4. Press any key other than `y`. The TUI cancels the delete and
+ the row stays.
+
+## 3. vm.toml name differs from directory
+
+Verifies every operation keys off the directory, never the `name` field. This
+identity bug recurred before. It must stay fixed.
+
+1. Stop the TUI. In a working VM at `~/.stoat/realdir/vm.toml`, change the `name`
+ field to `wrongname` (leave the directory `realdir`).
+2. Launch the TUI. The row shows `realdir`, the directory, not `wrongname`.
+3. Press `l` for details. Run each key below. Each one must act on this VM, and
+ none may report "not found":
+ - `E` opens `realdir/vm.toml` in `$EDITOR`.
+ - `L` shows this VM's console log.
+ - `s` opens an ssh session to it (VM must be running).
+ - `p` starts a provision run against it.
+4. Restore the `name` field afterward if you care about it.
+
+## 4. Snapshots modal (S), four states
+
+Verifies the `S` modal and both confirmation-gated destructive paths. Snapshots
+need a disk or cloud VM, not live.
+
+1. On the detail screen of a VM with no snapshots, press `S`. The modal
+ opens and shows an empty-state line, not a blank box.
+2. Press `d`, then `r`. Neither does anything on an empty list. `esc` closes the
+ modal.
+3. Take a snapshot first (`stoat snapshot ` from a shell), then reopen
+ the detail screen and press `S`. The snapshot lists by its tag and date.
+4. Press `d`. A `delete clean? y/N` line appears. Press a non-`y` key. The TUI
+ deletes nothing. Press `d` then `y`. The TUI removes the snapshot and
+ refreshes the list.
+5. Take another snapshot. Press `r`. A `restore clean? y/N` line appears. Confirm
+ with `y` and the VM's disk rolls back to that snapshot.
+6. Run steps 1-5 once with the VM stopped and once running. Both must behave the
+ same. A running VM's `info snapshots` prints `--` for the ID. The modal keys
+ everything off the tag for that reason.
diff --git a/internal/apkovl/apkovl.go b/internal/apkovl/apkovl.go
index cdd4ac5..cc50895 100644
--- a/internal/apkovl/apkovl.go
+++ b/internal/apkovl/apkovl.go
@@ -90,9 +90,16 @@ fi
// here (see installScript's ttyS0 redirect), so the window would otherwise show
// only a bare login prompt with no sign that an unattended install is running.
const installIssue = `
- stoat is installing Alpine on this VM.
- It runs unattended and powers off when done. Do not log in.
- Watch progress from the host: stoat logs
+ ┌───────────────────────────────────────────────────────┐
+ │ │
+ │ Installing Alpine. │
+ │ │
+ │ (◕ ᴥ ◕) Unattended. Powers off when done. │
+ │ Do not log in. │
+ │ │
+ │ Watch progress: stoat logs │
+ │ │
+ └───────────────────────────────────────────────────────┘
`
diff --git a/internal/apkovl/apkovl_test.go b/internal/apkovl/apkovl_test.go
index 965adba..322e7db 100644
--- a/internal/apkovl/apkovl_test.go
+++ b/internal/apkovl/apkovl_test.go
@@ -327,7 +327,7 @@ func TestBuildIncludesInstallStageForDiskMode(t *testing.T) {
t.Errorf("stoat-install.start does not redirect to the captured serial port:\n%s", script)
}
// The VGA console shows a splash instead of a bare login prompt.
- if !strings.Contains(content["etc/issue"], "installing") {
+ if !strings.Contains(content["etc/issue"], "Installing") {
t.Errorf("etc/issue missing the install banner: %q", content["etc/issue"])
}
if m := hdrs["etc/local.d/stoat-install.start"].Mode; m&0o111 == 0 {
diff --git a/internal/cli/subcommands_test.go b/internal/cli/subcommands_test.go
index f9f37b3..ab75835 100644
--- a/internal/cli/subcommands_test.go
+++ b/internal/cli/subcommands_test.go
@@ -171,24 +171,23 @@ func TestRecipesListsAndFilters(t *testing.T) {
t.Fatal("recipes returned nothing with no filter")
}
- // debian only satisfies xfce's OS list (the other bundled recipes all
- // require alpine-only capabilities like apk/openrc), so filtering to it
- // is strictly narrower than the full catalog.
+ // debian satisfies xfce, devtools, docker, and tailscale OS lists.
_, only := runJSON(t, "recipes", "--os", "debian", "--backend", "cloudinit")
debian, _ := dataOf(t, only)["recipes"].([]any)
if len(debian) == 0 {
t.Fatal("recipes --os debian --backend cloudinit returned nothing")
}
- if len(debian) >= len(every) {
- t.Errorf("filtered %d is not narrower than unfiltered %d", len(debian), len(every))
- }
+ want := map[string]bool{"xfce": true, "devtools": true, "docker": true, "tailscale": true}
for _, r := range debian {
m, _ := r.(map[string]any)
name, _ := m["name"].(string)
- if name != "xfce" {
- t.Errorf("debian/cloudinit offered %q, want only xfce", name)
+ if !want[name] {
+ t.Errorf("debian/cloudinit offered unexpected recipe %q", name)
}
}
+ if len(debian) != len(want) {
+ t.Errorf("debian/cloudinit offered %d recipes, want %d", len(debian), len(want))
+ }
}
func TestRecipesUnknownOSIsAnEmptyArray(t *testing.T) {
@@ -223,8 +222,8 @@ func TestCheckRecipesApplicableAndNot(t *testing.T) {
t.Errorf("issues = %v, want empty", issues)
}
- // docker requires alpine's apk/openrc; debian cannot run it.
- code, objs = runJSON(t, "check-recipes", "docker", "--os", "debian", "--backend", "cloudinit")
+ // A non-existent recipe is inapplicable to any OS.
+ code, objs = runJSON(t, "check-recipes", "nosuchrecipe", "--os", "debian", "--backend", "cloudinit")
if code != ExitOK {
t.Fatalf("inapplicable: exit = %d, want 0: checking SUCCEEDED", code)
}
diff --git a/internal/core/apply_test.go b/internal/core/apply_test.go
index 7d65ce7..89b7249 100644
--- a/internal/core/apply_test.go
+++ b/internal/core/apply_test.go
@@ -245,22 +245,21 @@ func TestCheckRecipesOKRecipeReportsNoIssue(t *testing.T) {
}
}
-// A recipe requested for an OS its recipe.toml doesn't declare (docker is
-// alpine-only) reports the OS mismatch from recipes.MatchReason.
+// A recipe requested for an OS its recipe.toml doesn't declare reports the OS
+// mismatch from recipes.MatchReason. The bundled recipes now ship for every OS,
+// so this pins a synthetic alpine-only recipe.
func TestCheckRecipesReportsOSMismatch(t *testing.T) {
- root(t)
- if err := recipes.Install(); err != nil {
- t.Fatal(err)
- }
+ dir := root(t)
+ writeV2RecipeWithOS(t, dir, "alpineonly", "once", "1.0", []string{"alpine"}, "#!/bin/sh\necho test\n")
- issues, err := CheckRecipes("debian", "apkovl", []string{"docker"})
+ issues, err := CheckRecipes("debian", "apkovl", []string{"alpineonly"})
if err != nil {
t.Fatal(err)
}
if len(issues) != 1 {
t.Fatalf("issues = %+v, want exactly 1", issues)
}
- want := "docker: built for alpine, not debian"
+ want := "alpineonly: built for alpine, not debian"
if !strings.Contains(issues[0].Reason, want) {
t.Errorf("Reason = %q, want it to contain %q", issues[0].Reason, want)
}
@@ -312,6 +311,30 @@ func writeDepRecipe(t *testing.T, rootDir, name, run string, depends []string) {
}
}
+// writeV2RecipeWithOS is like writeV2Recipe but also sets the os field.
+func writeV2RecipeWithOS(t *testing.T, rootDir, name, run, version string, oses []string, script string) {
+ t.Helper()
+ recipeDir := filepath.Join(rootDir, "recipes", name)
+ if err := os.MkdirAll(recipeDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ osLine := ""
+ if len(oses) > 0 {
+ osLine = "os = [\"" + strings.Join(oses, "\", \"") + "\"]\n"
+ }
+ toml := "name = \"" + name + "\"\n" +
+ "version = \"" + version + "\"\n" +
+ osLine +
+ "script = \"install.sh\"\n" +
+ "run = \"" + run + "\"\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(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+}
+
// TestPlanApplyOnStoppedVM pins the dry-run plan: it is computed host-side, so
// a stopped VM (never started here) still returns a correct run/skip plan. An
// applied "once" recipe with a matching hash skips; an unapplied one runs.
diff --git a/internal/core/needs_provision.go b/internal/core/needs_provision.go
index a018205..8e29725 100644
--- a/internal/core/needs_provision.go
+++ b/internal/core/needs_provision.go
@@ -9,12 +9,12 @@ import "github.com/novusedge/stoat/internal/config"
// 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.
+// For cloud VMs, this may return true on first boot (before discoverCloudInitApplied
+// has populated v.Applied). Apply handles that: it reads the markers cloud-init
+// left, populates Applied, and skips recipes already run. A minor no-op pass
+// on first boot is acceptable; it's the price of supporting recipes added
+// after creation.
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
@@ -22,5 +22,5 @@ func NeedsProvision(v *config.VM) (bool, error) {
if len(runTargets) > 0 {
return true, nil
}
- return v.Mode == "disk" && v.Share != "", nil
+ return (v.Mode == "disk" || v.Mode == "cloud") && v.Share != "", nil
}
diff --git a/internal/core/needs_provision_test.go b/internal/core/needs_provision_test.go
index 80865fa..c59dacb 100644
--- a/internal/core/needs_provision_test.go
+++ b/internal/core/needs_provision_test.go
@@ -89,17 +89,42 @@ func TestNeedsProvisionDiskWithShareEvenWhenApplied(t *testing.T) {
}
}
-// 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"}}
+// TestNeedsProvisionCloudUnapplied: a cloud VM with recipes not yet in Applied
+// returns true. On first boot this triggers a no-op pass (discoverCloudInitApplied
+// finds the markers); for recipes added after creation it runs them.
+func TestNeedsProvisionCloudUnapplied(t *testing.T) {
+ dir := root(t)
+ writeV2Recipe(t, dir, "tool", "once", "1.0", "#!/bin/sh\necho one\n")
+ v := &config.VM{Mode: "cloud", 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 (or markers not yet discovered)")
+ }
+}
+
+// TestNeedsProvisionCloudAllApplied: a cloud VM with all recipes already in
+// Applied (markers discovered, or added and run) returns false.
+func TestNeedsProvisionCloudAllApplied(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: "cloud", 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: a cloud VM provisions through cloud-init")
+ t.Error("got true, want false: all recipes already applied")
}
}
diff --git a/internal/recipes/bundled/devtools/install-alpine.sh b/internal/recipes/bundled/devtools/install-alpine.sh
new file mode 100755
index 0000000..fbd7f5d
--- /dev/null
+++ b/internal/recipes/bundled/devtools/install-alpine.sh
@@ -0,0 +1,45 @@
+#!/bin/sh
+# The baseline you end up installing on every throwaway VM: version control, a
+# compiler, an editor, and the tools to fetch things. Runs as root over ssh on
+# a booted Alpine VM.
+set -e
+
+# -c enables community (where most of these live outside the base set); -1
+# picks a mirror and refreshes indexes, so no separate `apk update`.
+# setup-apkrepos runs apk update with no lock-wait, so another apk that holds
+# the database lock fails it with exit 99. Retry until the lock frees, up to
+# ~60s.
+n=0
+until setup-apkrepos -c -1; do
+ n=$((n + 1))
+ [ "$n" -ge 30 ] && { echo "apk database stayed locked; giving up" >&2; exit 1; }
+ sleep 2
+done
+
+# build-base is Alpine's meta-package for gcc/make/libc-dev: the equivalent of
+# Debian's build-essential. Alpine has no package called "build-essential".
+# --wait 60 makes apk wait up to 60s for the lock instead of failing with
+# exit 99 when another apk run holds it.
+apk --wait 60 add git curl ca-certificates build-base vim tmux less
+
+# Alpine's default shell is ash and there is no bash unless asked for; scripts
+# copied in from elsewhere routinely assume it, so it is part of a baseline.
+apk --wait 60 add bash
+
+git --version
+echo "devtools installed: git, curl, build-base (gcc/make), vim, tmux, less, bash"
+
+# Live VMs are diskless: the root filesystem is a tmpfs/overlay in RAM, so
+# every package installed above is gone on reboot. A disk install mounts a
+# real block device as root, which persists. Detecting it from inside the
+# guest (rather than assuming) is the same mechanism xfce.alpine.sh uses.
+root_fstype=$(awk '$2 == "/" { print $3 }' /proc/mounts)
+
+case "$root_fstype" in
+tmpfs | overlay)
+ echo "NOTE: this is a live VM (root is $root_fstype, in RAM). Everything installed above is gone after a reboot. Rebooting will NOT bring it back. Use a disk VM to keep it."
+ ;;
+*)
+ echo "installed on a disk VM (root is $root_fstype), so this survives a reboot."
+ ;;
+esac
diff --git a/internal/recipes/bundled/devtools/install-arch.sh b/internal/recipes/bundled/devtools/install-arch.sh
new file mode 100755
index 0000000..601901c
--- /dev/null
+++ b/internal/recipes/bundled/devtools/install-arch.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+# git, a compiler, an editor and basic fetch tools. Runs as root over ssh on a
+# booted Arch VM.
+set -e
+
+pacman -Sy --noconfirm git curl ca-certificates base-devel vim tmux less bash
+
+git --version
+echo "devtools installed: git, curl, base-devel (gcc/make), vim, tmux, less, bash"
diff --git a/internal/recipes/bundled/devtools/install-debian.sh b/internal/recipes/bundled/devtools/install-debian.sh
new file mode 100755
index 0000000..197fc85
--- /dev/null
+++ b/internal/recipes/bundled/devtools/install-debian.sh
@@ -0,0 +1,11 @@
+#!/bin/sh
+# git, a compiler, an editor and basic fetch tools. Runs as root over ssh on a
+# booted Ubuntu or Debian VM.
+set -e
+
+export DEBIAN_FRONTEND=noninteractive
+apt-get update
+apt-get install -y git curl ca-certificates build-essential vim tmux less bash
+
+git --version
+echo "devtools installed: git, curl, build-essential (gcc/make), vim, tmux, less, bash"
diff --git a/internal/recipes/bundled/devtools/install-fedora.sh b/internal/recipes/bundled/devtools/install-fedora.sh
new file mode 100755
index 0000000..1d4bba8
--- /dev/null
+++ b/internal/recipes/bundled/devtools/install-fedora.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+# git, a compiler, an editor and basic fetch tools. Runs as root over ssh on a
+# booted Fedora VM.
+set -e
+
+dnf install -y git curl ca-certificates gcc make vim tmux less bash
+
+git --version
+echo "devtools installed: git, curl, gcc, make, vim, tmux, less, bash"
diff --git a/internal/recipes/bundled/devtools/install.sh b/internal/recipes/bundled/devtools/install.sh
old mode 100644
new mode 100755
index fbd7f5d..3c81163
--- a/internal/recipes/bundled/devtools/install.sh
+++ b/internal/recipes/bundled/devtools/install.sh
@@ -1,45 +1,11 @@
#!/bin/sh
-# The baseline you end up installing on every throwaway VM: version control, a
-# compiler, an editor, and the tools to fetch things. Runs as root over ssh on
-# a booted Alpine VM.
+# git, a compiler, an editor and basic fetch tools. Default script for OSes not
+# explicitly listed in [scripts]. Assumes apt-get (Debian-family).
set -e
-# -c enables community (where most of these live outside the base set); -1
-# picks a mirror and refreshes indexes, so no separate `apk update`.
-# setup-apkrepos runs apk update with no lock-wait, so another apk that holds
-# the database lock fails it with exit 99. Retry until the lock frees, up to
-# ~60s.
-n=0
-until setup-apkrepos -c -1; do
- n=$((n + 1))
- [ "$n" -ge 30 ] && { echo "apk database stayed locked; giving up" >&2; exit 1; }
- sleep 2
-done
-
-# build-base is Alpine's meta-package for gcc/make/libc-dev: the equivalent of
-# Debian's build-essential. Alpine has no package called "build-essential".
-# --wait 60 makes apk wait up to 60s for the lock instead of failing with
-# exit 99 when another apk run holds it.
-apk --wait 60 add git curl ca-certificates build-base vim tmux less
-
-# Alpine's default shell is ash and there is no bash unless asked for; scripts
-# copied in from elsewhere routinely assume it, so it is part of a baseline.
-apk --wait 60 add bash
+export DEBIAN_FRONTEND=noninteractive
+apt-get update
+apt-get install -y git curl ca-certificates build-essential vim tmux less bash
git --version
-echo "devtools installed: git, curl, build-base (gcc/make), vim, tmux, less, bash"
-
-# Live VMs are diskless: the root filesystem is a tmpfs/overlay in RAM, so
-# every package installed above is gone on reboot. A disk install mounts a
-# real block device as root, which persists. Detecting it from inside the
-# guest (rather than assuming) is the same mechanism xfce.alpine.sh uses.
-root_fstype=$(awk '$2 == "/" { print $3 }' /proc/mounts)
-
-case "$root_fstype" in
-tmpfs | overlay)
- echo "NOTE: this is a live VM (root is $root_fstype, in RAM). Everything installed above is gone after a reboot. Rebooting will NOT bring it back. Use a disk VM to keep it."
- ;;
-*)
- echo "installed on a disk VM (root is $root_fstype), so this survives a reboot."
- ;;
-esac
+echo "devtools installed: git, curl, build-essential (gcc/make), vim, tmux, less, bash"
diff --git a/internal/recipes/bundled/devtools/recipe.toml b/internal/recipes/bundled/devtools/recipe.toml
index ea85d5a..998df3f 100644
--- a/internal/recipes/bundled/devtools/recipe.toml
+++ b/internal/recipes/bundled/devtools/recipe.toml
@@ -1,6 +1,12 @@
name = "devtools"
description = "git, a compiler, an editor and basic fetch tools"
-os = ["alpine"]
-requires = ["apk"]
+os = ["alpine", "ubuntu", "debian", "fedora", "arch"]
stage = "provision"
script = "install.sh"
+
+[scripts]
+alpine = "install-alpine.sh"
+ubuntu = "install-debian.sh"
+debian = "install-debian.sh"
+fedora = "install-fedora.sh"
+arch = "install-arch.sh"
diff --git a/internal/recipes/bundled/docker/install-alpine.sh b/internal/recipes/bundled/docker/install-alpine.sh
new file mode 100755
index 0000000..eb2c6d4
--- /dev/null
+++ b/internal/recipes/bundled/docker/install-alpine.sh
@@ -0,0 +1,56 @@
+#!/bin/sh
+# Installs Docker and the compose plugin. Runs as root over ssh on a booted
+# Alpine VM.
+set -e
+
+# -c enables the community repository, which is where docker lives (main has
+# no docker package at all). -1 picks the fastest mirror and refreshes the
+# indexes, so a separate `apk update` would be redundant work that only widens
+# the window for a transient network drop to kill the run under `set -e`.
+# setup-apkrepos runs apk update with no lock-wait, so another apk that holds
+# the database lock fails it with exit 99. Retry until the lock frees, up to
+# ~60s.
+n=0
+until setup-apkrepos -c -1; do
+ n=$((n + 1))
+ [ "$n" -ge 30 ] && { echo "apk database stayed locked; giving up" >&2; exit 1; }
+ sleep 2
+done
+
+# Verified against pkgs.alpinelinux.org: both are in community, and compose is
+# a SEPARATE package from docker (docker alone gives you no `docker compose`).
+# --wait 60 makes apk wait up to 60s for the lock instead of failing with
+# exit 99 when another apk run holds it.
+apk --wait 60 add docker docker-cli-compose
+
+rc-update add docker default
+rc-service docker start
+
+# The daemon takes a moment to open its socket; without this the very next
+# command a user types fails with "Cannot connect to the Docker daemon" on a
+# machine where docker is in fact fine.
+i=0
+while [ $i -lt 30 ]; do
+ docker info >/dev/null 2>&1 && break
+ i=$((i + 1))
+ sleep 1
+done
+
+docker version --format '{{.Server.Version}}' 2>/dev/null |
+ sed 's/^/docker daemon running, version /' ||
+ echo "docker installed, but the daemon did not come up: check 'rc-service docker status'"
+
+# Live VMs are diskless: the root filesystem is a tmpfs/overlay in RAM, so
+# every package installed above is gone on reboot. A disk install mounts a
+# real block device as root, which persists. Detecting it from inside the
+# guest (rather than assuming) is the same mechanism xfce.alpine.sh uses.
+root_fstype=$(awk '$2 == "/" { print $3 }' /proc/mounts)
+
+case "$root_fstype" in
+tmpfs | overlay)
+ echo "NOTE: this is a live VM (root is $root_fstype, in RAM). Everything installed above is gone after a reboot. Rebooting will NOT bring it back. Use a disk VM to keep it."
+ ;;
+*)
+ echo "installed on a disk VM (root is $root_fstype), so this survives a reboot."
+ ;;
+esac
diff --git a/internal/recipes/bundled/docker/install-arch.sh b/internal/recipes/bundled/docker/install-arch.sh
new file mode 100755
index 0000000..6c8fd2a
--- /dev/null
+++ b/internal/recipes/bundled/docker/install-arch.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+# Installs Docker and the compose plugin. Runs as root over ssh on a booted
+# Arch VM.
+set -e
+
+pacman -Sy --noconfirm docker docker-compose
+
+systemctl enable docker
+systemctl start docker
+
+# Wait for daemon
+i=0
+while [ $i -lt 30 ]; do
+ docker info >/dev/null 2>&1 && break
+ i=$((i + 1))
+ sleep 1
+done
+
+docker version --format '{{.Server.Version}}' 2>/dev/null |
+ sed 's/^/docker daemon running, version /' ||
+ echo "docker installed, but the daemon did not come up: check 'systemctl status docker'"
diff --git a/internal/recipes/bundled/docker/install-debian.sh b/internal/recipes/bundled/docker/install-debian.sh
new file mode 100755
index 0000000..053e639
--- /dev/null
+++ b/internal/recipes/bundled/docker/install-debian.sh
@@ -0,0 +1,40 @@
+#!/bin/sh
+# Installs Docker and the compose plugin. Runs as root over ssh on a booted
+# Ubuntu or Debian VM.
+set -e
+
+export DEBIAN_FRONTEND=noninteractive
+
+# Install prerequisites
+apt-get update
+apt-get install -y ca-certificates curl gnupg
+
+# Add Docker's official GPG key
+install -m 0755 -d /etc/apt/keyrings
+curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg | \
+ gpg --dearmor -o /etc/apt/keyrings/docker.gpg
+chmod a+r /etc/apt/keyrings/docker.gpg
+
+# Add the repository
+echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
+https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") \
+$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
+ tee /etc/apt/sources.list.d/docker.list > /dev/null
+
+apt-get update
+apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
+
+systemctl enable docker
+systemctl start docker
+
+# Wait for daemon
+i=0
+while [ $i -lt 30 ]; do
+ docker info >/dev/null 2>&1 && break
+ i=$((i + 1))
+ sleep 1
+done
+
+docker version --format '{{.Server.Version}}' 2>/dev/null |
+ sed 's/^/docker daemon running, version /' ||
+ echo "docker installed, but the daemon did not come up: check 'systemctl status docker'"
diff --git a/internal/recipes/bundled/docker/install-fedora.sh b/internal/recipes/bundled/docker/install-fedora.sh
new file mode 100755
index 0000000..dd4321b
--- /dev/null
+++ b/internal/recipes/bundled/docker/install-fedora.sh
@@ -0,0 +1,25 @@
+#!/bin/sh
+# Installs Docker and the compose plugin. Runs as root over ssh on a booted
+# Fedora VM.
+set -e
+
+# Add Docker's official repository
+dnf -y install dnf-plugins-core
+dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo
+
+dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
+
+systemctl enable docker
+systemctl start docker
+
+# Wait for daemon
+i=0
+while [ $i -lt 30 ]; do
+ docker info >/dev/null 2>&1 && break
+ i=$((i + 1))
+ sleep 1
+done
+
+docker version --format '{{.Server.Version}}' 2>/dev/null |
+ sed 's/^/docker daemon running, version /' ||
+ echo "docker installed, but the daemon did not come up: check 'systemctl status docker'"
diff --git a/internal/recipes/bundled/docker/install.sh b/internal/recipes/bundled/docker/install.sh
old mode 100644
new mode 100755
index eb2c6d4..e291bbe
--- a/internal/recipes/bundled/docker/install.sh
+++ b/internal/recipes/bundled/docker/install.sh
@@ -1,56 +1,36 @@
#!/bin/sh
-# Installs Docker and the compose plugin. Runs as root over ssh on a booted
-# Alpine VM.
+# Installs Docker and the compose plugin. Default script for OSes not explicitly
+# listed in [scripts]. Assumes apt-get and systemd (Debian-family).
set -e
-# -c enables the community repository, which is where docker lives (main has
-# no docker package at all). -1 picks the fastest mirror and refreshes the
-# indexes, so a separate `apk update` would be redundant work that only widens
-# the window for a transient network drop to kill the run under `set -e`.
-# setup-apkrepos runs apk update with no lock-wait, so another apk that holds
-# the database lock fails it with exit 99. Retry until the lock frees, up to
-# ~60s.
-n=0
-until setup-apkrepos -c -1; do
- n=$((n + 1))
- [ "$n" -ge 30 ] && { echo "apk database stayed locked; giving up" >&2; exit 1; }
- sleep 2
-done
+export DEBIAN_FRONTEND=noninteractive
+
+apt-get update
+apt-get install -y ca-certificates curl gnupg
+
+install -m 0755 -d /etc/apt/keyrings
+curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg | \
+ gpg --dearmor -o /etc/apt/keyrings/docker.gpg
+chmod a+r /etc/apt/keyrings/docker.gpg
+
+echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
+https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") \
+$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
+ tee /etc/apt/sources.list.d/docker.list > /dev/null
-# Verified against pkgs.alpinelinux.org: both are in community, and compose is
-# a SEPARATE package from docker (docker alone gives you no `docker compose`).
-# --wait 60 makes apk wait up to 60s for the lock instead of failing with
-# exit 99 when another apk run holds it.
-apk --wait 60 add docker docker-cli-compose
+apt-get update
+apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
-rc-update add docker default
-rc-service docker start
+systemctl enable docker
+systemctl start docker
-# The daemon takes a moment to open its socket; without this the very next
-# command a user types fails with "Cannot connect to the Docker daemon" on a
-# machine where docker is in fact fine.
i=0
while [ $i -lt 30 ]; do
- docker info >/dev/null 2>&1 && break
- i=$((i + 1))
- sleep 1
+ docker info >/dev/null 2>&1 && break
+ i=$((i + 1))
+ sleep 1
done
docker version --format '{{.Server.Version}}' 2>/dev/null |
- sed 's/^/docker daemon running, version /' ||
- echo "docker installed, but the daemon did not come up: check 'rc-service docker status'"
-
-# Live VMs are diskless: the root filesystem is a tmpfs/overlay in RAM, so
-# every package installed above is gone on reboot. A disk install mounts a
-# real block device as root, which persists. Detecting it from inside the
-# guest (rather than assuming) is the same mechanism xfce.alpine.sh uses.
-root_fstype=$(awk '$2 == "/" { print $3 }' /proc/mounts)
-
-case "$root_fstype" in
-tmpfs | overlay)
- echo "NOTE: this is a live VM (root is $root_fstype, in RAM). Everything installed above is gone after a reboot. Rebooting will NOT bring it back. Use a disk VM to keep it."
- ;;
-*)
- echo "installed on a disk VM (root is $root_fstype), so this survives a reboot."
- ;;
-esac
+ sed 's/^/docker daemon running, version /' ||
+ echo "docker installed, but the daemon did not come up: check 'systemctl status docker'"
diff --git a/internal/recipes/bundled/docker/recipe.toml b/internal/recipes/bundled/docker/recipe.toml
index a88c206..46ff55c 100644
--- a/internal/recipes/bundled/docker/recipe.toml
+++ b/internal/recipes/bundled/docker/recipe.toml
@@ -1,6 +1,12 @@
name = "docker"
description = "Docker engine and the compose plugin"
-os = ["alpine"]
-requires = ["apk", "openrc"]
+os = ["alpine", "ubuntu", "debian", "fedora", "arch"]
stage = "provision"
script = "install.sh"
+
+[scripts]
+alpine = "install-alpine.sh"
+ubuntu = "install-debian.sh"
+debian = "install-debian.sh"
+fedora = "install-fedora.sh"
+arch = "install-arch.sh"
diff --git a/internal/recipes/bundled/tailscale/install-alpine.sh b/internal/recipes/bundled/tailscale/install-alpine.sh
new file mode 100755
index 0000000..fb3018e
--- /dev/null
+++ b/internal/recipes/bundled/tailscale/install-alpine.sh
@@ -0,0 +1,56 @@
+#!/bin/sh
+# Installs Tailscale and starts the daemon. Runs as root over ssh on a booted
+# Alpine VM.
+#
+# It deliberately does NOT authenticate. Joining a tailnet needs an auth key,
+# and stoat has nowhere to keep one that isn't worse than the alternative: a
+# key in vm.toml would sit in plaintext in the data root, and a key baked into
+# a recipe would end up in git. So this installs and starts the daemon, then
+# tells you the one command to run yourself.
+set -e
+
+# -c enables community, where tailscale lives; -1 picks a mirror and refreshes
+# indexes, so no separate `apk update`.
+# setup-apkrepos runs apk update with no lock-wait, so another apk that holds
+# the database lock fails it with exit 99. Retry until the lock frees, up to
+# ~60s.
+n=0
+until setup-apkrepos -c -1; do
+ n=$((n + 1))
+ [ "$n" -ge 30 ] && { echo "apk database stayed locked; giving up" >&2; exit 1; }
+ sleep 2
+done
+
+# --wait 60 makes apk wait up to 60s for the lock instead of failing with
+# exit 99 when another apk run holds it.
+apk --wait 60 add tailscale
+
+rc-update add tailscale default
+rc-service tailscale start
+
+# tailscaled needs a moment before `tailscale up` will talk to it.
+i=0
+while [ $i -lt 30 ]; do
+ tailscale status >/dev/null 2>&1 && break
+ i=$((i + 1))
+ sleep 1
+done
+
+echo "tailscale installed and tailscaled running."
+echo "To join your tailnet, ssh in and run: tailscale up"
+echo "(stoat does not store auth keys, see this recipe's header for why.)"
+
+# Live VMs are diskless: the root filesystem is a tmpfs/overlay in RAM, so
+# every package installed above is gone on reboot. A disk install mounts a
+# real block device as root, which persists. Detecting it from inside the
+# guest (rather than assuming) is the same mechanism xfce.alpine.sh uses.
+root_fstype=$(awk '$2 == "/" { print $3 }' /proc/mounts)
+
+case "$root_fstype" in
+tmpfs | overlay)
+ echo "NOTE: this is a live VM (root is $root_fstype, in RAM). Everything installed above is gone after a reboot. Rebooting will NOT bring it back. Use a disk VM to keep it."
+ ;;
+*)
+ echo "installed on a disk VM (root is $root_fstype), so this survives a reboot."
+ ;;
+esac
diff --git a/internal/recipes/bundled/tailscale/install-arch.sh b/internal/recipes/bundled/tailscale/install-arch.sh
new file mode 100755
index 0000000..70872a1
--- /dev/null
+++ b/internal/recipes/bundled/tailscale/install-arch.sh
@@ -0,0 +1,24 @@
+#!/bin/sh
+# Installs Tailscale and starts the daemon. Runs as root over ssh on a booted
+# Arch VM.
+#
+# Does NOT authenticate. Joining a tailnet needs an auth key, and stoat has
+# nowhere to keep one safely. This installs and starts the daemon, then tells
+# you the one command to run yourself.
+set -e
+
+pacman -Sy --noconfirm tailscale
+
+systemctl enable tailscaled
+systemctl start tailscaled
+
+# tailscaled needs a moment before `tailscale up` will talk to it
+i=0
+while [ $i -lt 30 ]; do
+ tailscale status >/dev/null 2>&1 && break
+ i=$((i + 1))
+ sleep 1
+done
+
+echo "tailscale installed and tailscaled running."
+echo "To join your tailnet, ssh in and run: tailscale up"
diff --git a/internal/recipes/bundled/tailscale/install-debian.sh b/internal/recipes/bundled/tailscale/install-debian.sh
new file mode 100755
index 0000000..a78461d
--- /dev/null
+++ b/internal/recipes/bundled/tailscale/install-debian.sh
@@ -0,0 +1,27 @@
+#!/bin/sh
+# Installs Tailscale and starts the daemon. Runs as root over ssh on a booted
+# Ubuntu or Debian VM.
+#
+# Does NOT authenticate. Joining a tailnet needs an auth key, and stoat has
+# nowhere to keep one safely. This installs and starts the daemon, then tells
+# you the one command to run yourself.
+set -e
+
+export DEBIAN_FRONTEND=noninteractive
+
+# Use Tailscale's official install script
+curl -fsSL https://tailscale.com/install.sh | sh
+
+systemctl enable tailscaled
+systemctl start tailscaled
+
+# tailscaled needs a moment before `tailscale up` will talk to it
+i=0
+while [ $i -lt 30 ]; do
+ tailscale status >/dev/null 2>&1 && break
+ i=$((i + 1))
+ sleep 1
+done
+
+echo "tailscale installed and tailscaled running."
+echo "To join your tailnet, ssh in and run: tailscale up"
diff --git a/internal/recipes/bundled/tailscale/install-fedora.sh b/internal/recipes/bundled/tailscale/install-fedora.sh
new file mode 100755
index 0000000..5d3fdd1
--- /dev/null
+++ b/internal/recipes/bundled/tailscale/install-fedora.sh
@@ -0,0 +1,25 @@
+#!/bin/sh
+# Installs Tailscale and starts the daemon. Runs as root over ssh on a booted
+# Fedora VM.
+#
+# Does NOT authenticate. Joining a tailnet needs an auth key, and stoat has
+# nowhere to keep one safely. This installs and starts the daemon, then tells
+# you the one command to run yourself.
+set -e
+
+# Use Tailscale's official install script
+curl -fsSL https://tailscale.com/install.sh | sh
+
+systemctl enable tailscaled
+systemctl start tailscaled
+
+# tailscaled needs a moment before `tailscale up` will talk to it
+i=0
+while [ $i -lt 30 ]; do
+ tailscale status >/dev/null 2>&1 && break
+ i=$((i + 1))
+ sleep 1
+done
+
+echo "tailscale installed and tailscaled running."
+echo "To join your tailnet, ssh in and run: tailscale up"
diff --git a/internal/recipes/bundled/tailscale/install.sh b/internal/recipes/bundled/tailscale/install.sh
old mode 100644
new mode 100755
index fb3018e..cb1cd3f
--- a/internal/recipes/bundled/tailscale/install.sh
+++ b/internal/recipes/bundled/tailscale/install.sh
@@ -1,56 +1,23 @@
#!/bin/sh
-# Installs Tailscale and starts the daemon. Runs as root over ssh on a booted
-# Alpine VM.
+# Installs Tailscale and starts the daemon. Default script for OSes not explicitly
+# listed in [scripts]. Uses Tailscale's official install script.
#
-# It deliberately does NOT authenticate. Joining a tailnet needs an auth key,
-# and stoat has nowhere to keep one that isn't worse than the alternative: a
-# key in vm.toml would sit in plaintext in the data root, and a key baked into
-# a recipe would end up in git. So this installs and starts the daemon, then
-# tells you the one command to run yourself.
+# Does NOT authenticate. Joining a tailnet needs an auth key, and stoat has
+# nowhere to keep one safely. This installs and starts the daemon, then tells
+# you the one command to run yourself.
set -e
-# -c enables community, where tailscale lives; -1 picks a mirror and refreshes
-# indexes, so no separate `apk update`.
-# setup-apkrepos runs apk update with no lock-wait, so another apk that holds
-# the database lock fails it with exit 99. Retry until the lock frees, up to
-# ~60s.
-n=0
-until setup-apkrepos -c -1; do
- n=$((n + 1))
- [ "$n" -ge 30 ] && { echo "apk database stayed locked; giving up" >&2; exit 1; }
- sleep 2
-done
-
-# --wait 60 makes apk wait up to 60s for the lock instead of failing with
-# exit 99 when another apk run holds it.
-apk --wait 60 add tailscale
+curl -fsSL https://tailscale.com/install.sh | sh
-rc-update add tailscale default
-rc-service tailscale start
+systemctl enable tailscaled
+systemctl start tailscaled
-# tailscaled needs a moment before `tailscale up` will talk to it.
i=0
while [ $i -lt 30 ]; do
- tailscale status >/dev/null 2>&1 && break
- i=$((i + 1))
- sleep 1
+ tailscale status >/dev/null 2>&1 && break
+ i=$((i + 1))
+ sleep 1
done
echo "tailscale installed and tailscaled running."
echo "To join your tailnet, ssh in and run: tailscale up"
-echo "(stoat does not store auth keys, see this recipe's header for why.)"
-
-# Live VMs are diskless: the root filesystem is a tmpfs/overlay in RAM, so
-# every package installed above is gone on reboot. A disk install mounts a
-# real block device as root, which persists. Detecting it from inside the
-# guest (rather than assuming) is the same mechanism xfce.alpine.sh uses.
-root_fstype=$(awk '$2 == "/" { print $3 }' /proc/mounts)
-
-case "$root_fstype" in
-tmpfs | overlay)
- echo "NOTE: this is a live VM (root is $root_fstype, in RAM). Everything installed above is gone after a reboot. Rebooting will NOT bring it back. Use a disk VM to keep it."
- ;;
-*)
- echo "installed on a disk VM (root is $root_fstype), so this survives a reboot."
- ;;
-esac
diff --git a/internal/recipes/bundled/tailscale/recipe.toml b/internal/recipes/bundled/tailscale/recipe.toml
index 32c518e..d8e4147 100644
--- a/internal/recipes/bundled/tailscale/recipe.toml
+++ b/internal/recipes/bundled/tailscale/recipe.toml
@@ -1,6 +1,12 @@
name = "tailscale"
description = "Tailscale daemon, installed and started (join manually)"
-os = ["alpine"]
-requires = ["apk", "openrc"]
+os = ["alpine", "ubuntu", "debian", "fedora", "arch"]
stage = "provision"
script = "install.sh"
+
+[scripts]
+alpine = "install-alpine.sh"
+ubuntu = "install-debian.sh"
+debian = "install-debian.sh"
+fedora = "install-fedora.sh"
+arch = "install-arch.sh"
diff --git a/internal/recipes/recipes_test.go b/internal/recipes/recipes_test.go
index 7add22c..eb67356 100644
--- a/internal/recipes/recipes_test.go
+++ b/internal/recipes/recipes_test.go
@@ -220,18 +220,14 @@ func TestListFiltersbyOS(t *testing.T) {
}
}
- // Ubuntu should get xfce (has ubuntu in OS list) but not alpine-only recipes
+ // Ubuntu should get xfce, devtools, docker, tailscale (all have ubuntu in OS list)
ubuntuRecipes, err := List("ubuntu", "ssh")
if err != nil {
t.Fatal(err)
}
- if !contains(ubuntuRecipes, "xfce") {
- t.Errorf("List(ubuntu) missing xfce, got %v", ubuntuRecipes)
- }
- // docker/devtools/tailscale are alpine-only
- for _, notWant := range []string{"docker", "devtools", "tailscale"} {
- if contains(ubuntuRecipes, notWant) {
- t.Errorf("List(ubuntu) should not contain %q (alpine-only)", notWant)
+ for _, want := range []string{"xfce", "devtools", "docker", "tailscale"} {
+ if !contains(ubuntuRecipes, want) {
+ t.Errorf("List(ubuntu) missing %s, got %v", want, ubuntuRecipes)
}
}
}
diff --git a/internal/tui/autoprov.go b/internal/tui/autoprov.go
index 16b6238..315fb19 100644
--- a/internal/tui/autoprov.go
+++ b/internal/tui/autoprov.go
@@ -12,8 +12,7 @@ import (
)
// 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.
+// reachable, with no keypress.
// sshReadyMsg says a VM that was just started is now accepting ssh.
type sshReadyMsg struct{ name string }
@@ -71,8 +70,6 @@ func awaitSSH(v core.VM) tea.Cmd {
// needsAutoProvision reports whether stoat should provision v once it is
// reachable.
//
-// - 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.
@@ -80,11 +77,10 @@ func awaitSSH(v core.VM) tea.Cmd {
// 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.
+// - Everything else (including cloud VMs) defers to core.NeedsProvision.
+// Cloud-init runs the seed's recipes at first boot; core.Apply discovers
+// those via marker files and only runs new or changed recipes.
func needsAutoProvision(v core.VM) bool {
- if v.Mode == "cloud" {
- return false
- }
if v.Mode == "disk" && !v.Installed {
return false
}
diff --git a/internal/tui/autoprov_test.go b/internal/tui/autoprov_test.go
index 486844e..79cc521 100644
--- a/internal/tui/autoprov_test.go
+++ b/internal/tui/autoprov_test.go
@@ -52,7 +52,8 @@ func TestNeedsAutoProvision(t *testing.T) {
{"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},
+ {"cloud, unapplied recipes", autoVM(t, "cloud", recipes), true},
+ {"cloud, no recipes", autoVM(t, "cloud", nil), false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {