From 8fc96c256a9bbb03b9cd40c9b4ce667f3633a231 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Thu, 13 Aug 2026 20:41:35 +0300 Subject: [PATCH 1/4] feat(recipes): ship docker, devtools, tailscale for debian, ubuntu, fedora, arch Each recipe declared os = ["alpine"]. They now declare all five OSes and carry a [scripts] map that points each OS at its own install-.sh. The old install.sh stays as the apt/systemd default for unlisted Debian-family OSes. Manifest.ScriptFor already resolves the [scripts] override, so no Go change is needed here. CheckRecipes no longer rejects these recipes on non-alpine OSes. The OS-mismatch test pins a synthetic alpine-only recipe instead of docker, which now applies everywhere. --- internal/cli/subcommands_test.go | 19 +++-- internal/core/apply_test.go | 39 ++++++++--- .../bundled/devtools/install-alpine.sh | 45 ++++++++++++ .../recipes/bundled/devtools/install-arch.sh | 9 +++ .../bundled/devtools/install-debian.sh | 11 +++ .../bundled/devtools/install-fedora.sh | 9 +++ internal/recipes/bundled/devtools/install.sh | 46 ++---------- internal/recipes/bundled/devtools/recipe.toml | 10 ++- .../recipes/bundled/docker/install-alpine.sh | 56 +++++++++++++++ .../recipes/bundled/docker/install-arch.sh | 21 ++++++ .../recipes/bundled/docker/install-debian.sh | 40 +++++++++++ .../recipes/bundled/docker/install-fedora.sh | 25 +++++++ internal/recipes/bundled/docker/install.sh | 70 +++++++------------ internal/recipes/bundled/docker/recipe.toml | 10 ++- .../bundled/tailscale/install-alpine.sh | 56 +++++++++++++++ .../recipes/bundled/tailscale/install-arch.sh | 24 +++++++ .../bundled/tailscale/install-debian.sh | 27 +++++++ .../bundled/tailscale/install-fedora.sh | 25 +++++++ internal/recipes/bundled/tailscale/install.sh | 55 +++------------ .../recipes/bundled/tailscale/recipe.toml | 10 ++- internal/recipes/recipes_test.go | 12 ++-- 21 files changed, 458 insertions(+), 161 deletions(-) create mode 100755 internal/recipes/bundled/devtools/install-alpine.sh create mode 100755 internal/recipes/bundled/devtools/install-arch.sh create mode 100755 internal/recipes/bundled/devtools/install-debian.sh create mode 100755 internal/recipes/bundled/devtools/install-fedora.sh mode change 100644 => 100755 internal/recipes/bundled/devtools/install.sh create mode 100755 internal/recipes/bundled/docker/install-alpine.sh create mode 100755 internal/recipes/bundled/docker/install-arch.sh create mode 100755 internal/recipes/bundled/docker/install-debian.sh create mode 100755 internal/recipes/bundled/docker/install-fedora.sh mode change 100644 => 100755 internal/recipes/bundled/docker/install.sh create mode 100755 internal/recipes/bundled/tailscale/install-alpine.sh create mode 100755 internal/recipes/bundled/tailscale/install-arch.sh create mode 100755 internal/recipes/bundled/tailscale/install-debian.sh create mode 100755 internal/recipes/bundled/tailscale/install-fedora.sh mode change 100644 => 100755 internal/recipes/bundled/tailscale/install.sh 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/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) } } } From 23cbd2d16c1920f788b1104c1a68fa0f64c26c19 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Thu, 13 Aug 2026 20:41:43 +0300 Subject: [PATCH 2/4] fix(provision): auto-provision cloud VMs through core.Apply needsAutoProvision returned false for cloud VMs, on the assumption cloud-init did all the work at first boot. A cloud VM with unapplied or changed recipes then never re-provisioned. core.Apply discovers cloud-init's first-boot runs via marker files, so it only runs new or changed recipes. Defer cloud VMs to NeedsProvision like every other mode. --- internal/core/needs_provision.go | 12 ++++----- internal/core/needs_provision_test.go | 37 ++++++++++++++++++++++----- internal/tui/autoprov.go | 12 +++------ internal/tui/autoprov_test.go | 3 ++- 4 files changed, 43 insertions(+), 21 deletions(-) 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/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) { From 2db7a85a4b7299b13c6820b913d2f0bfe7e7fcdf Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Thu, 13 Aug 2026 20:41:49 +0300 Subject: [PATCH 3/4] feat(apkovl): box the unattended-install banner on the VM console The install issue text was three bare lines. Draw it in a box with a small mascot so a human watching the console sees an unattended install is running, not a stalled login prompt. --- internal/apkovl/apkovl.go | 13 ++++++++++--- internal/apkovl/apkovl_test.go | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) 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 { From edf3ed13ad24dd4c75c1916bf87ec9e2e8919bed Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Thu, 13 Aug 2026 20:41:59 +0300 Subject: [PATCH 4/4] docs(qa): add manual TUI click-through checklist The list/detail screen checks an agent cannot run: they need a real Alpine boot and a human reading the screen. Covers uptime tick, broken vm.toml delete, directory-vs-name identity, and the snapshots modal. --- docs/qa/tui-clickthrough.md | 79 +++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/qa/tui-clickthrough.md 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.