diff --git a/CHANGELOG.md b/CHANGELOG.md index 967e8da9..1beec20b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## Unreleased + +### Features + +- Apply the `devtools` and `python-dev` recipes on AlmaLinux, Rocky, and + openSUSE. All eight bundled guests now carry both recipes. +- Apply the `build-deps`, `service-tools`, and `pkg-tools` recipes on all eight + bundled guests. `build-deps` installs a C compiler, `make`, and + `pkg-config`, then reports each as an output. `service-tools` installs + `lsof`, `strace`, and the process tools, then reports whether the guest runs + systemd or OpenRC, and where `lsof` and `strace` are. `pkg-tools` installs + the tool that answers which package owns a file, then reports that tool and + the package manager. + +### Fixes + +- `stoat up` no longer hangs on a guest whose cloud-init keeps its run + directory root-only. Stoat polls `cloud-init status` on its own deadline and + retries an unreadable probe under the guest's escalation. Fedora 44 ships the + cloud-init version that caused the hang. + ## v0.3.0 Three enterprise Linux guests, common developer recipes, and read-only diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 976f36bb..085ccc50 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,9 +89,9 @@ reviewer rejects a PR that ignores them. ## Recipes -Bundled recipes are `devtools`, `docker`, `python-dev`, `tailscale`, and -`xfce`. The bundled catalog changes only through an approved design. Do not -add opportunistic recipe IDs. `devtools` and `python-dev` are the two common +Bundled recipes are `devtools`, `python-dev`, `build-deps`, `service-tools`, +`pkg-tools`, `docker`, `tailscale`, and `xfce`. The bundled catalog changes +only through an approved design. Do not add opportunistic recipe IDs. `devtools` and `python-dev` are the two common developer recipes. Write a new recipe in `~/.stoat/recipes//` from `stoat recipe new`; see `docs/recipes/writing-your-own.md` and `docs/recipes/sharing.md` for installing and pinning remote recipes. Every diff --git a/docs/recipes/catalog.md b/docs/recipes/catalog.md new file mode 100644 index 00000000..d9b4dedd --- /dev/null +++ b/docs/recipes/catalog.md @@ -0,0 +1,115 @@ +# The bundled recipe catalog + +This page says what stoat bundles, why each entry is there, and what keeps the +set from growing without limit. + +## Rules + +- Two common developer recipes stay as they are: `devtools` and `python-dev`. +- A task that exists on every guest gets one recipe with a script per OS + family. It does not get one recipe per OS. +- A recipe earns its place by needing real OS-specific behaviour. A package + name that differs is not enough on its own; a package group, a pattern, or a + different init system is. +- At most three OS-specific recipes per guest, for tasks with no counterpart + elsewhere. +- Every recipe declares schema 3 outputs and a health check, applies safely a + second time, and names no user account of its own. + +## What is bundled + +| Recipe | Guests | Purpose | +|---|---|---| +| `devtools` | all eight | Git, a compiler, an editor, basic fetch tools | +| `python-dev` | all eight | Python 3, pip, an isolated environment | +| `build-deps` | all eight | What building someone else's source tree needs | +| `service-tools` | all eight | Inspecting a running service and the processes behind it | +| `pkg-tools` | all eight | Querying the package manager beyond install and remove | +| `docker` | five | Docker engine and the compose plugin | +| `tailscale` | five | `tailscaled`, started and joined | +| `xfce` | four | XFCE with autologin on tty1 | + +`devtools` and `build-deps` overlap on a compiler and `make`. The boundary is +the task: `devtools` equips the VM for writing code in it, and `build-deps` +equips it for compiling a project that expects autotools, `pkg-config`, and the +distribution's own packaging headers. On Arch the boundary does not hold: +`devtools` installs `base-devel`, and `build-deps` installs only `base-devel`, +so `build-deps` is a subset of `devtools` on that guest. + +## The three new common recipes + +Each installs the tooling for one task. None writes a report, because a report +generated at provision time is stale by the first time anyone reads it. + +### build-deps + +| Family | Packages | +|---|---| +| Debian, Ubuntu | `build-essential`, `pkg-config`, `autoconf`, `automake`, `libtool`, `dpkg-dev` | +| Fedora, AlmaLinux, Rocky | the development tools group, `pkgconf-pkg-config`, `rpm-build` | +| openSUSE | pattern `devel_basis` | +| Arch | `base-devel` | +| Alpine | `alpine-sdk`, `build-base` | + +Outputs: `compiler`, `make`, `pkg_config`. +Health: each of the three responds to `--version`. + +The RPM and openSUSE entries are a group and a pattern, which the package +manager expands. The group carries two ids: dnf5 on Fedora calls it +`development-tools`, and dnf4 on AlmaLinux 9 and Rocky 9 calls it +`development`. The script asks for the first id and falls back to the second. Arch's `base-devel` is a metapackage that pulls the same +tools in one name. That is the OS-specific behaviour this recipe exists for. + +### service-tools + +| Family | Packages | +|---|---| +| Debian, Ubuntu | `lsof`, `strace`, `procps` | +| Fedora, AlmaLinux, Rocky | `lsof`, `strace`, `procps-ng` | +| openSUSE | `lsof`, `strace`, `procps` | +| Arch | `lsof`, `strace`, `procps-ng` | +| Alpine | `lsof`, `strace`, `procps`, `openrc` | + +Outputs: `service_manager` (`systemd` or `openrc`), `lsof`, `strace`. +Health: the service manager answers a status query, and `lsof -v` runs. + +Alpine runs OpenRC and every other bundled guest runs systemd, so the health +check and the reported manager differ by guest rather than by package name. + +### pkg-tools + +| Family | Packages | +|---|---| +| Debian, Ubuntu | `apt-file`, `dpkg-dev` | +| Fedora, AlmaLinux, Rocky | `dnf-utils` | +| openSUSE | `zypper`, `libzypp` | +| Arch | `pacman-contrib` | +| Alpine | `apk-tools` | + +Outputs: `query_tool` (the binary that answers "which package owns this file"), +`manager`. +Health: the query tool runs. + +## OS-specific candidates, not yet selected + +These have no counterpart on the other guests. None is implemented. Each needs +its own contract tests and its own live qualification before it is claimed. + +| Guest | Candidate | Why it is OS-specific | Open question | +|---|---|---|---| +| AlmaLinux, Rocky, Fedora | SELinux tooling: `setroubleshoot-server`, `policycoreutils-python-utils` | SELinux exists only on the RPM family | Does a VM with SELinux in permissive mode need it? | +| openSUSE | `osc`, the Open Build Service client | OBS is openSUSE's own build service | Is a build-service client in scope for a local VM tool? | +| Arch | `namcap`, PKGBUILD linting | PKGBUILD is Arch's format | Overlaps `base-devel` from `build-deps` | +| Alpine | `abuild`, APKBUILD tooling | APKBUILD is Alpine's format | Overlaps `alpine-sdk` from `build-deps` | +| Debian, Ubuntu | `devscripts`, `lintian` | Debian packaging tooling | Overlaps `dpkg-dev` from `build-deps` | + +Four of the five overlap a common recipe, which is the rule in this page doing +its job. Only the SELinux entry is clearly separate, and it needs a decision +about whether a permissive-mode VM benefits from it. + +## Qualification + +A recipe is claimed for a guest after it has been applied on that guest's +advertised release, from a binary built at a known commit, with the recipe +reporting healthy and its declared outputs resolving to real executables in the +guest. The retained evidence names the run, the binary hash, and the commit. diff --git a/docs/recipes/overview.md b/docs/recipes/overview.md index cdd98022..a2733fc0 100644 --- a/docs/recipes/overview.md +++ b/docs/recipes/overview.md @@ -67,29 +67,32 @@ succeeded. It does not reboot live VMs because a live root is temporary. ## Bundled recipes The bundled catalog changes only through an approved design. Do not add -opportunistic recipe IDs. It currently contains these five recipes: +opportunistic recipe IDs. It currently contains these eight recipes: | Recipe | Supported guests | Purpose | |---|---|---| | `devtools` | Alpine, Ubuntu, Debian, Fedora, Arch, AlmaLinux, Rocky, openSUSE | Git, compiler tools, editor and basic fetch tools | -| `docker` | Alpine, Ubuntu, Debian, Fedora, Arch | Docker engine and compose plugin; schema 3 parameter `user`, output `socket`, health check `docker info` | | `python-dev` | Alpine, Ubuntu, Debian, Fedora, Arch, AlmaLinux, Rocky, openSUSE | Python 3, pip, and an isolated development environment; schema 3 parameters `user` and optional `venv_dir`, with smoke-only mode when `venv_dir` is empty | +| `build-deps` | Alpine, Ubuntu, Debian, Fedora, Arch, AlmaLinux, Rocky, openSUSE | What building someone else's source tree needs; schema 3 outputs `compiler`, `make`, `pkg_config`, health check each responds to `--version` | +| `service-tools` | Alpine, Ubuntu, Debian, Fedora, Arch, AlmaLinux, Rocky, openSUSE | Inspecting a running service and the processes behind it; schema 3 outputs `service_manager` (`systemd` or `openrc`), `lsof`, `strace`, health check service manager answers status query and `lsof -v` runs | +| `pkg-tools` | Alpine, Ubuntu, Debian, Fedora, Arch, AlmaLinux, Rocky, openSUSE | Querying the package manager beyond install and remove; schema 3 outputs `query_tool`, `manager`, health check query tool runs | +| `docker` | Alpine, Ubuntu, Debian, Fedora, Arch | Docker engine and compose plugin; schema 3 parameter `user`, output `socket`, health check `docker info` | | `tailscale` | Alpine, Ubuntu, Debian, Fedora, Arch | Install and start `tailscaled`; schema 3 required secret `authkey`, health check `tailscale version` | | `xfce` | Alpine, Ubuntu, Debian, Arch | XFCE desktop with autologin startx on tty1; requests a disk-VM reboot | -`devtools` and `python-dev` are the two common developer recipes. The -remaining bundled recipes are existing capabilities and are not part of that -developer pair. +`devtools`, `python-dev`, `build-deps`, `service-tools`, and `pkg-tools` run +on all guests. `docker`, `tailscale`, and `xfce` require capabilities present +only on some guests. `python-dev` requires the configured guest account. For example, a cloud guest whose SSH account is `stoat` must use `python-dev.user=stoat`; an Alpine guest using `root` must use `python-dev.user=root`. The scripts are in `internal/recipes/bundled/` in the source tree. Each -recipe has a manifest. Docker, devtools, python-dev, and Tailscale use -OS-specific script overrides because package names and repository setup differ. -XFCE uses one script with guest prelude verbs and therefore has no `[scripts]` -table. +recipe has a manifest. Docker, devtools, python-dev, Tailscale, build-deps, +service-tools, and pkg-tools use OS-specific script overrides because package +names and repository setup differ. XFCE uses one script with guest prelude +verbs and therefore has no `[scripts]` table. Cloud images use their own package manager and a cloud-init seed. Cloud-init currently wraps recipe bodies in shell commands; it does not perform the SSH diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a7a3f91d..f326ae2a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -518,8 +518,12 @@ not an additional manifest-match condition. ``` $ stoat recipes --os alpine --backend apkovl NAME DESCRIPTION +build-deps toolchain for building software from source devtools git, a compiler, an editor and basic fetch tools docker Docker engine and the compose plugin +pkg-tools tools for querying the package manager +python-dev Python 3 with pip and an isolated development environment +service-tools tools for inspecting services and the processes behind them tailscale Tailscale daemon, installed and started (join manually) xfce XFCE desktop with autologin startx on tty1 ``` @@ -555,8 +559,12 @@ search order. ``` $ stoat recipe list NAME SCOPE COMMIT DESCRIPTION +build-deps bundled toolchain for building software from source devtools bundled git, a compiler, an editor and basic fetch tools docker bundled Docker engine and the compose plugin +pkg-tools bundled tools for querying the package manager +python-dev bundled Python 3 with pip and an isolated development environment +service-tools bundled tools for inspecting services and the processes behind them tailscale bundled Tailscale daemon, installed and started (join manually) xfce bundled XFCE desktop with autologin startx on tty1 ``` diff --git a/internal/cli/subcommands_test.go b/internal/cli/subcommands_test.go index 31d57e39..27035edb 100644 --- a/internal/cli/subcommands_test.go +++ b/internal/cli/subcommands_test.go @@ -237,13 +237,13 @@ func TestRecipesListsAndFilters(t *testing.T) { t.Fatal("recipes returned nothing with no filter") } - // debian satisfies xfce, devtools, docker, python-dev, and tailscale OS lists. + // debian satisfies xfce, devtools, docker, python-dev, build-deps, service-tools, pkg-tools, 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") } - want := map[string]bool{"xfce": true, "devtools": true, "docker": true, "python-dev": true, "tailscale": true} + want := map[string]bool{"xfce": true, "devtools": true, "docker": true, "python-dev": true, "tailscale": true, "build-deps": true, "service-tools": true, "pkg-tools": true} for _, r := range debian { m, _ := r.(map[string]any) name, _ := m["name"].(string) diff --git a/internal/recipes/bundled/build-deps/install-alpine.sh b/internal/recipes/bundled/build-deps/install-alpine.sh new file mode 100755 index 00000000..a958ba8e --- /dev/null +++ b/internal/recipes/bundled/build-deps/install-alpine.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# toolchain for building software from source. Runs as root over ssh on a +# booted Alpine VM. +set -e + +# -c enables community (alpine-sdk lives there, 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 + +stoat_pkg_install alpine-sdk build-base + +compiler=$(command -v cc 2>/dev/null || command -v gcc 2>/dev/null || true) +make_bin=$(command -v make 2>/dev/null || true) +pkg_config=$(command -v pkg-config 2>/dev/null || command -v pkgconf 2>/dev/null || true) +[ -n "$compiler" ] || { echo "build-deps: C compiler was not installed" >&2; exit 1; } +[ -n "$make_bin" ] || { echo "build-deps: make was not installed" >&2; exit 1; } +[ -n "$pkg_config" ] || { echo "build-deps: pkg-config was not installed" >&2; exit 1; } +if [ -n "${STOAT_OUTPUT:-}" ]; then + { + printf 'compiler=%s\n' "$compiler" + printf 'make=%s\n' "$make_bin" + printf 'pkg_config=%s\n' "$pkg_config" + } >> "$STOAT_OUTPUT" +fi +echo "build-deps installed: alpine-sdk, build-base" diff --git a/internal/recipes/bundled/build-deps/install-arch.sh b/internal/recipes/bundled/build-deps/install-arch.sh new file mode 100755 index 00000000..91c32422 --- /dev/null +++ b/internal/recipes/bundled/build-deps/install-arch.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# toolchain for building software from source. Runs as root over ssh on a +# booted Arch VM. +set -e + +stoat_pkg_setup +stoat_pkg_install base-devel + +compiler=$(command -v cc 2>/dev/null || command -v gcc 2>/dev/null || true) +make_bin=$(command -v make 2>/dev/null || true) +pkg_config=$(command -v pkg-config 2>/dev/null || command -v pkgconf 2>/dev/null || true) +[ -n "$compiler" ] || { echo "build-deps: C compiler was not installed" >&2; exit 1; } +[ -n "$make_bin" ] || { echo "build-deps: make was not installed" >&2; exit 1; } +[ -n "$pkg_config" ] || { echo "build-deps: pkg-config was not installed" >&2; exit 1; } +if [ -n "${STOAT_OUTPUT:-}" ]; then + { + printf 'compiler=%s\n' "$compiler" + printf 'make=%s\n' "$make_bin" + printf 'pkg_config=%s\n' "$pkg_config" + } >> "$STOAT_OUTPUT" +fi +echo "build-deps installed: base-devel (gcc/make/pkg-config)" diff --git a/internal/recipes/bundled/build-deps/install-debian.sh b/internal/recipes/bundled/build-deps/install-debian.sh new file mode 100755 index 00000000..d5646b43 --- /dev/null +++ b/internal/recipes/bundled/build-deps/install-debian.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# toolchain for building software from source. Runs as root over ssh on a +# booted Ubuntu or Debian VM. +set -e + +export DEBIAN_FRONTEND=noninteractive +stoat_pkg_setup +stoat_pkg_install build-essential pkg-config autoconf automake libtool dpkg-dev + +compiler=$(command -v cc 2>/dev/null || command -v gcc 2>/dev/null || true) +make_bin=$(command -v make 2>/dev/null || true) +pkg_config=$(command -v pkg-config 2>/dev/null || command -v pkgconf 2>/dev/null || true) +[ -n "$compiler" ] || { echo "build-deps: C compiler was not installed" >&2; exit 1; } +[ -n "$make_bin" ] || { echo "build-deps: make was not installed" >&2; exit 1; } +[ -n "$pkg_config" ] || { echo "build-deps: pkg-config was not installed" >&2; exit 1; } +if [ -n "${STOAT_OUTPUT:-}" ]; then + { + printf 'compiler=%s\n' "$compiler" + printf 'make=%s\n' "$make_bin" + printf 'pkg_config=%s\n' "$pkg_config" + } >> "$STOAT_OUTPUT" +fi +echo "build-deps installed: build-essential, pkg-config, autoconf, automake, libtool, dpkg-dev" diff --git a/internal/recipes/bundled/build-deps/install-rpm.sh b/internal/recipes/bundled/build-deps/install-rpm.sh new file mode 100755 index 00000000..bfac39da --- /dev/null +++ b/internal/recipes/bundled/build-deps/install-rpm.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# toolchain for building software from source. Runs as root on a booted dnf +# guest: Fedora, AlmaLinux or Rocky. +set -e + +stoat_pkg_setup +# The group carries the same packages under two ids: dnf5 on Fedora calls it +# development-tools, and dnf4 on AlmaLinux 9 and Rocky 9 calls it development. +# dnf fails with "Module or Group is not available" on the wrong id. +if ! stoat_pkg_install @development-tools; then + stoat_pkg_install @development +fi +stoat_pkg_install pkgconf-pkg-config rpm-build + +compiler=$(command -v cc 2>/dev/null || command -v gcc 2>/dev/null || true) +make_bin=$(command -v make 2>/dev/null || true) +pkg_config=$(command -v pkg-config 2>/dev/null || command -v pkgconf 2>/dev/null || true) +[ -n "$compiler" ] || { echo "build-deps: C compiler was not installed" >&2; exit 1; } +[ -n "$make_bin" ] || { echo "build-deps: make was not installed" >&2; exit 1; } +[ -n "$pkg_config" ] || { echo "build-deps: pkg-config was not installed" >&2; exit 1; } +if [ -n "${STOAT_OUTPUT:-}" ]; then + { + printf 'compiler=%s\n' "$compiler" + printf 'make=%s\n' "$make_bin" + printf 'pkg_config=%s\n' "$pkg_config" + } >> "$STOAT_OUTPUT" +fi +echo "build-deps installed: the development tools group, pkgconf-pkg-config, rpm-build" diff --git a/internal/recipes/bundled/build-deps/install-zypper.sh b/internal/recipes/bundled/build-deps/install-zypper.sh new file mode 100755 index 00000000..13b17458 --- /dev/null +++ b/internal/recipes/bundled/build-deps/install-zypper.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# toolchain for building software from source. Runs as root on a booted +# openSUSE VM. +set -e + +stoat_pkg_setup +stoat_pkg_install -t pattern devel_basis + +compiler=$(command -v cc 2>/dev/null || command -v gcc 2>/dev/null || true) +make_bin=$(command -v make 2>/dev/null || true) +pkg_config=$(command -v pkg-config 2>/dev/null || command -v pkgconf 2>/dev/null || true) +[ -n "$compiler" ] || { echo "build-deps: C compiler was not installed" >&2; exit 1; } +[ -n "$make_bin" ] || { echo "build-deps: make was not installed" >&2; exit 1; } +[ -n "$pkg_config" ] || { echo "build-deps: pkg-config was not installed" >&2; exit 1; } +if [ -n "${STOAT_OUTPUT:-}" ]; then + { + printf 'compiler=%s\n' "$compiler" + printf 'make=%s\n' "$make_bin" + printf 'pkg_config=%s\n' "$pkg_config" + } >> "$STOAT_OUTPUT" +fi +echo "build-deps installed: devel_basis pattern" diff --git a/internal/recipes/bundled/build-deps/recipe.toml b/internal/recipes/bundled/build-deps/recipe.toml new file mode 100644 index 00000000..79a92921 --- /dev/null +++ b/internal/recipes/bundled/build-deps/recipe.toml @@ -0,0 +1,24 @@ +name = "build-deps" +description = "toolchain for building software from source" +os = ["alpine", "ubuntu", "debian", "fedora", "arch", "almalinux", "rocky", "opensuse"] +stage = "provision" +script = "install-debian.sh" +schema = 3 + +[scripts] +alpine = "install-alpine.sh" +ubuntu = "install-debian.sh" +debian = "install-debian.sh" +fedora = "install-rpm.sh" +almalinux = "install-rpm.sh" +rocky = "install-rpm.sh" +opensuse = "install-zypper.sh" +arch = "install-arch.sh" + +[outputs] +compiler = "path to the C compiler" +make = "path to make" +pkg_config = "path to pkg-config" + +[health] +check = "cc --version >/dev/null 2>&1 && make --version >/dev/null 2>&1 && { pkg-config --version >/dev/null 2>&1 || pkgconf --version >/dev/null 2>&1; }" diff --git a/internal/recipes/bundled/pkg-tools/install-alpine.sh b/internal/recipes/bundled/pkg-tools/install-alpine.sh new file mode 100755 index 00000000..2ffd1873 --- /dev/null +++ b/internal/recipes/bundled/pkg-tools/install-alpine.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# tools for querying the package manager. Runs as root over ssh on a booted +# Alpine VM. +set -e + +# -1 picks a mirror and refreshes indexes, so no separate stoat_pkg_setup call +# is needed here. -c is kept even though apk-tools lives in Alpine's main +# repository, so the script does not depend on which repo a package happens +# to live in today. 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 + +stoat_pkg_install apk-tools + +manager=$(command -v apt-get 2>/dev/null || command -v dnf 2>/dev/null || command -v zypper 2>/dev/null || command -v pacman 2>/dev/null || command -v apk 2>/dev/null || true) +query_tool=$(command -v apk 2>/dev/null || true) +[ -n "$manager" ] || { echo "pkg-tools: no supported package manager found" >&2; exit 1; } +[ -n "$query_tool" ] || { echo "pkg-tools: apk was not installed" >&2; exit 1; } +if [ -n "${STOAT_OUTPUT:-}" ]; then + { + printf 'manager=%s\n' "$manager" + printf 'query_tool=%s\n' "$query_tool" + } >> "$STOAT_OUTPUT" +fi +echo "pkg-tools installed: apk-tools" diff --git a/internal/recipes/bundled/pkg-tools/install-arch.sh b/internal/recipes/bundled/pkg-tools/install-arch.sh new file mode 100755 index 00000000..49a98b46 --- /dev/null +++ b/internal/recipes/bundled/pkg-tools/install-arch.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# tools for querying the package manager. Runs as root over ssh on a booted +# Arch VM. +set -e + +stoat_pkg_setup +stoat_pkg_install pacman-contrib + +manager=$(command -v apt-get 2>/dev/null || command -v dnf 2>/dev/null || command -v zypper 2>/dev/null || command -v pacman 2>/dev/null || command -v apk 2>/dev/null || true) +query_tool=$(command -v pacman 2>/dev/null || true) +[ -n "$manager" ] || { echo "pkg-tools: no supported package manager found" >&2; exit 1; } +[ -n "$query_tool" ] || { echo "pkg-tools: pacman was not installed" >&2; exit 1; } +if [ -n "${STOAT_OUTPUT:-}" ]; then + { + printf 'manager=%s\n' "$manager" + printf 'query_tool=%s\n' "$query_tool" + } >> "$STOAT_OUTPUT" +fi +echo "pkg-tools installed: pacman-contrib" diff --git a/internal/recipes/bundled/pkg-tools/install-debian.sh b/internal/recipes/bundled/pkg-tools/install-debian.sh new file mode 100755 index 00000000..8647300a --- /dev/null +++ b/internal/recipes/bundled/pkg-tools/install-debian.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# tools for querying the package manager. Runs as root over ssh on a booted +# Ubuntu or Debian VM. +set -e + +export DEBIAN_FRONTEND=noninteractive +stoat_pkg_setup +stoat_pkg_install apt-file dpkg-dev + +manager=$(command -v apt-get 2>/dev/null || command -v dnf 2>/dev/null || command -v zypper 2>/dev/null || command -v pacman 2>/dev/null || command -v apk 2>/dev/null || true) +query_tool=$(command -v apt-file 2>/dev/null || true) +[ -n "$manager" ] || { echo "pkg-tools: no supported package manager found" >&2; exit 1; } +[ -n "$query_tool" ] || { echo "pkg-tools: apt-file was not installed" >&2; exit 1; } +if [ -n "${STOAT_OUTPUT:-}" ]; then + { + printf 'manager=%s\n' "$manager" + printf 'query_tool=%s\n' "$query_tool" + } >> "$STOAT_OUTPUT" +fi +echo "pkg-tools installed: apt-file, dpkg-dev" diff --git a/internal/recipes/bundled/pkg-tools/install-rpm.sh b/internal/recipes/bundled/pkg-tools/install-rpm.sh new file mode 100755 index 00000000..81a8f58d --- /dev/null +++ b/internal/recipes/bundled/pkg-tools/install-rpm.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# tools for querying the package manager. Runs as root on a booted dnf guest: +# Fedora, AlmaLinux or Rocky. +set -e + +stoat_pkg_setup +stoat_pkg_install dnf-utils + +manager=$(command -v apt-get 2>/dev/null || command -v dnf 2>/dev/null || command -v zypper 2>/dev/null || command -v pacman 2>/dev/null || command -v apk 2>/dev/null || true) +query_tool=$(command -v repoquery 2>/dev/null || true) +[ -n "$manager" ] || { echo "pkg-tools: no supported package manager found" >&2; exit 1; } +[ -n "$query_tool" ] || { echo "pkg-tools: repoquery was not installed" >&2; exit 1; } +if [ -n "${STOAT_OUTPUT:-}" ]; then + { + printf 'manager=%s\n' "$manager" + printf 'query_tool=%s\n' "$query_tool" + } >> "$STOAT_OUTPUT" +fi +echo "pkg-tools installed: dnf-utils" diff --git a/internal/recipes/bundled/pkg-tools/install-zypper.sh b/internal/recipes/bundled/pkg-tools/install-zypper.sh new file mode 100755 index 00000000..d1b10ff5 --- /dev/null +++ b/internal/recipes/bundled/pkg-tools/install-zypper.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# tools for querying the package manager. Runs as root on a booted openSUSE +# VM. +set -e + +stoat_pkg_setup +stoat_pkg_install zypper libzypp + +manager=$(command -v apt-get 2>/dev/null || command -v dnf 2>/dev/null || command -v zypper 2>/dev/null || command -v pacman 2>/dev/null || command -v apk 2>/dev/null || true) +query_tool=$(command -v zypper 2>/dev/null || true) +[ -n "$manager" ] || { echo "pkg-tools: no supported package manager found" >&2; exit 1; } +[ -n "$query_tool" ] || { echo "pkg-tools: zypper was not installed" >&2; exit 1; } +if [ -n "${STOAT_OUTPUT:-}" ]; then + { + printf 'manager=%s\n' "$manager" + printf 'query_tool=%s\n' "$query_tool" + } >> "$STOAT_OUTPUT" +fi +echo "pkg-tools installed: zypper, libzypp" diff --git a/internal/recipes/bundled/pkg-tools/recipe.toml b/internal/recipes/bundled/pkg-tools/recipe.toml new file mode 100644 index 00000000..a90fb7ca --- /dev/null +++ b/internal/recipes/bundled/pkg-tools/recipe.toml @@ -0,0 +1,23 @@ +name = "pkg-tools" +description = "tools for querying the package manager" +os = ["alpine", "ubuntu", "debian", "fedora", "arch", "almalinux", "rocky", "opensuse"] +stage = "provision" +script = "install-debian.sh" +schema = 3 + +[scripts] +alpine = "install-alpine.sh" +ubuntu = "install-debian.sh" +debian = "install-debian.sh" +fedora = "install-rpm.sh" +almalinux = "install-rpm.sh" +rocky = "install-rpm.sh" +opensuse = "install-zypper.sh" +arch = "install-arch.sh" + +[outputs] +query_tool = "path to the tool that answers which package owns a file" +manager = "path to the package manager binary" + +[health] +check = "(command -v apt-file >/dev/null 2>&1 && apt-file --help >/dev/null 2>&1) || (command -v repoquery >/dev/null 2>&1 && repoquery --version >/dev/null 2>&1) || (command -v zypper >/dev/null 2>&1 && zypper --version >/dev/null 2>&1) || (command -v pacman >/dev/null 2>&1 && pacman --version >/dev/null 2>&1) || (command -v apk >/dev/null 2>&1 && apk --version >/dev/null 2>&1)" diff --git a/internal/recipes/bundled/service-tools/install-alpine.sh b/internal/recipes/bundled/service-tools/install-alpine.sh new file mode 100755 index 00000000..0bf4b1ae --- /dev/null +++ b/internal/recipes/bundled/service-tools/install-alpine.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# tools for inspecting services and the processes behind them. Runs as root +# over ssh on a booted Alpine VM. +set -e + +# -1 picks a mirror and refreshes indexes, so no separate `apk update` and no +# stoat_pkg_setup call after this loop. -c is kept even though every package +# here lives in Alpine's main repository, so the script does not depend on +# which repo a package happens to live in today. +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 + +stoat_pkg_install lsof strace procps openrc + +service_manager="" +if command -v systemctl >/dev/null 2>&1; then + service_manager=systemd +elif command -v rc-status >/dev/null 2>&1; then + service_manager=openrc +fi +lsof_bin=$(command -v lsof 2>/dev/null || true) +strace_bin=$(command -v strace 2>/dev/null || true) +[ -n "$service_manager" ] || { echo "service-tools: no known service manager (systemd or openrc) was found" >&2; exit 1; } +[ -n "$lsof_bin" ] || { echo "service-tools: lsof was not installed" >&2; exit 1; } +[ -n "$strace_bin" ] || { echo "service-tools: strace was not installed" >&2; exit 1; } +if [ -n "${STOAT_OUTPUT:-}" ]; then + { + printf 'service_manager=%s\n' "$service_manager" + printf 'lsof=%s\n' "$lsof_bin" + printf 'strace=%s\n' "$strace_bin" + } >> "$STOAT_OUTPUT" +fi +echo "service-tools installed: lsof, strace, procps, openrc" diff --git a/internal/recipes/bundled/service-tools/install-arch.sh b/internal/recipes/bundled/service-tools/install-arch.sh new file mode 100755 index 00000000..e24fa96d --- /dev/null +++ b/internal/recipes/bundled/service-tools/install-arch.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# tools for inspecting services and the processes behind them. Runs as root +# over ssh on a booted Arch VM. +set -e + +stoat_pkg_setup +stoat_pkg_install lsof strace procps-ng + +service_manager="" +if command -v systemctl >/dev/null 2>&1; then + service_manager=systemd +elif command -v rc-status >/dev/null 2>&1; then + service_manager=openrc +fi +lsof_bin=$(command -v lsof 2>/dev/null || true) +strace_bin=$(command -v strace 2>/dev/null || true) +[ -n "$service_manager" ] || { echo "service-tools: no known service manager (systemd or openrc) was found" >&2; exit 1; } +[ -n "$lsof_bin" ] || { echo "service-tools: lsof was not installed" >&2; exit 1; } +[ -n "$strace_bin" ] || { echo "service-tools: strace was not installed" >&2; exit 1; } +if [ -n "${STOAT_OUTPUT:-}" ]; then + { + printf 'service_manager=%s\n' "$service_manager" + printf 'lsof=%s\n' "$lsof_bin" + printf 'strace=%s\n' "$strace_bin" + } >> "$STOAT_OUTPUT" +fi +echo "service-tools installed: lsof, strace, procps-ng" diff --git a/internal/recipes/bundled/service-tools/install-debian.sh b/internal/recipes/bundled/service-tools/install-debian.sh new file mode 100755 index 00000000..688cf6e5 --- /dev/null +++ b/internal/recipes/bundled/service-tools/install-debian.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# tools for inspecting services and the processes behind them. Runs as root +# over ssh on a booted Ubuntu or Debian VM. +set -e + +export DEBIAN_FRONTEND=noninteractive +stoat_pkg_setup +stoat_pkg_install lsof strace procps + +service_manager="" +if command -v systemctl >/dev/null 2>&1; then + service_manager=systemd +elif command -v rc-status >/dev/null 2>&1; then + service_manager=openrc +fi +lsof_bin=$(command -v lsof 2>/dev/null || true) +strace_bin=$(command -v strace 2>/dev/null || true) +[ -n "$service_manager" ] || { echo "service-tools: no known service manager (systemd or openrc) was found" >&2; exit 1; } +[ -n "$lsof_bin" ] || { echo "service-tools: lsof was not installed" >&2; exit 1; } +[ -n "$strace_bin" ] || { echo "service-tools: strace was not installed" >&2; exit 1; } +if [ -n "${STOAT_OUTPUT:-}" ]; then + { + printf 'service_manager=%s\n' "$service_manager" + printf 'lsof=%s\n' "$lsof_bin" + printf 'strace=%s\n' "$strace_bin" + } >> "$STOAT_OUTPUT" +fi +echo "service-tools installed: lsof, strace, procps" diff --git a/internal/recipes/bundled/service-tools/install-rpm.sh b/internal/recipes/bundled/service-tools/install-rpm.sh new file mode 100755 index 00000000..2aa1de1a --- /dev/null +++ b/internal/recipes/bundled/service-tools/install-rpm.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# tools for inspecting services and the processes behind them. Runs as root on +# a booted dnf guest: Fedora, AlmaLinux or Rocky. +set -e + +stoat_pkg_setup +stoat_pkg_install lsof strace procps-ng + +service_manager="" +if command -v systemctl >/dev/null 2>&1; then + service_manager=systemd +elif command -v rc-status >/dev/null 2>&1; then + service_manager=openrc +fi +lsof_bin=$(command -v lsof 2>/dev/null || true) +strace_bin=$(command -v strace 2>/dev/null || true) +[ -n "$service_manager" ] || { echo "service-tools: no known service manager (systemd or openrc) was found" >&2; exit 1; } +[ -n "$lsof_bin" ] || { echo "service-tools: lsof was not installed" >&2; exit 1; } +[ -n "$strace_bin" ] || { echo "service-tools: strace was not installed" >&2; exit 1; } +if [ -n "${STOAT_OUTPUT:-}" ]; then + { + printf 'service_manager=%s\n' "$service_manager" + printf 'lsof=%s\n' "$lsof_bin" + printf 'strace=%s\n' "$strace_bin" + } >> "$STOAT_OUTPUT" +fi +echo "service-tools installed: lsof, strace, procps-ng" diff --git a/internal/recipes/bundled/service-tools/install-zypper.sh b/internal/recipes/bundled/service-tools/install-zypper.sh new file mode 100755 index 00000000..e9220154 --- /dev/null +++ b/internal/recipes/bundled/service-tools/install-zypper.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# tools for inspecting services and the processes behind them. Runs as root +# on a booted openSUSE VM. +set -e + +stoat_pkg_setup +stoat_pkg_install lsof strace procps + +service_manager="" +if command -v systemctl >/dev/null 2>&1; then + service_manager=systemd +elif command -v rc-status >/dev/null 2>&1; then + service_manager=openrc +fi +lsof_bin=$(command -v lsof 2>/dev/null || true) +strace_bin=$(command -v strace 2>/dev/null || true) +[ -n "$service_manager" ] || { echo "service-tools: no known service manager (systemd or openrc) was found" >&2; exit 1; } +[ -n "$lsof_bin" ] || { echo "service-tools: lsof was not installed" >&2; exit 1; } +[ -n "$strace_bin" ] || { echo "service-tools: strace was not installed" >&2; exit 1; } +if [ -n "${STOAT_OUTPUT:-}" ]; then + { + printf 'service_manager=%s\n' "$service_manager" + printf 'lsof=%s\n' "$lsof_bin" + printf 'strace=%s\n' "$strace_bin" + } >> "$STOAT_OUTPUT" +fi +echo "service-tools installed: lsof, strace, procps" diff --git a/internal/recipes/bundled/service-tools/recipe.toml b/internal/recipes/bundled/service-tools/recipe.toml new file mode 100644 index 00000000..b5a08c47 --- /dev/null +++ b/internal/recipes/bundled/service-tools/recipe.toml @@ -0,0 +1,24 @@ +name = "service-tools" +description = "tools for inspecting services and the processes behind them" +os = ["alpine", "ubuntu", "debian", "fedora", "arch", "almalinux", "rocky", "opensuse"] +stage = "provision" +script = "install-debian.sh" +schema = 3 + +[scripts] +alpine = "install-alpine.sh" +ubuntu = "install-debian.sh" +debian = "install-debian.sh" +fedora = "install-rpm.sh" +almalinux = "install-rpm.sh" +rocky = "install-rpm.sh" +opensuse = "install-zypper.sh" +arch = "install-arch.sh" + +[outputs] +service_manager = "systemd or openrc, whichever runs this guest" +lsof = "path to lsof" +strace = "path to strace" + +[health] +check = "( { command -v systemctl >/dev/null 2>&1 && systemctl list-units --type=service --no-pager --no-legend >/dev/null 2>&1; } || { command -v rc-status >/dev/null 2>&1 && rc-status >/dev/null 2>&1; } ) && command -v lsof >/dev/null 2>&1 && lsof -v >/dev/null 2>&1" diff --git a/internal/recipes/recipes_test.go b/internal/recipes/recipes_test.go index 25ad6b71..032f3ca7 100644 --- a/internal/recipes/recipes_test.go +++ b/internal/recipes/recipes_test.go @@ -179,7 +179,7 @@ func TestEmbedContainsBundledDirectory(t *testing.T) { if err != nil { t.Fatal(err) } - want := map[string]bool{"xfce": true, "docker": true, "devtools": true, "python-dev": true, "tailscale": true} + want := map[string]bool{"xfce": true, "docker": true, "devtools": true, "python-dev": true, "tailscale": true, "build-deps": true, "service-tools": true, "pkg-tools": true} for _, e := range recipes { if !e.IsDir() { continue diff --git a/internal/recipes/samples_test.go b/internal/recipes/samples_test.go index 44f209e4..c6f8ce12 100644 --- a/internal/recipes/samples_test.go +++ b/internal/recipes/samples_test.go @@ -147,6 +147,9 @@ func TestBundledCommonDeveloperRecipeContracts(t *testing.T) { }{ {name: "devtools", outputs: []string{"compiler", "editor", "git"}}, {name: "python-dev", outputs: []string{"pip", "python", "python_version", "venv", "venv_python"}}, + {name: "build-deps", outputs: []string{"compiler", "make", "pkg_config"}}, + {name: "service-tools", outputs: []string{"lsof", "service_manager", "strace"}}, + {name: "pkg-tools", outputs: []string{"manager", "query_tool"}}, } for _, tc := range checks { t.Run(tc.name, func(t *testing.T) { @@ -198,6 +201,379 @@ func TestBundledCommonDeveloperRecipeContracts(t *testing.T) { } } +func TestBundledBuildDepsInstallsPerFamily(t *testing.T) { + rpm := []string{"@development-tools", "pkgconf-pkg-config rpm-build"} + cases := []struct { + os string + requests []string + }{ + {os: "ubuntu", requests: []string{"build-essential pkg-config autoconf automake libtool dpkg-dev"}}, + {os: "debian", requests: []string{"build-essential pkg-config autoconf automake libtool dpkg-dev"}}, + {os: "fedora", requests: rpm}, + {os: "almalinux", requests: rpm}, + {os: "rocky", requests: rpm}, + {os: "opensuse", requests: []string{"-t pattern devel_basis"}}, + {os: "arch", requests: []string{"base-devel"}}, + {os: "alpine", requests: []string{"alpine-sdk build-base"}}, + } + for _, tc := range cases { + t.Run(tc.os, func(t *testing.T) { + body := bundledScript(t, "build-deps", tc.os) + root := t.TempDir() + outputPath := filepath.Join(root, "output") + callsPath := filepath.Join(root, "calls") + bin := filepath.Join(root, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"cc", "make", "pkg-config"} { + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + if tc.os == "alpine" { + if err := os.WriteFile(filepath.Join(bin, "setup-apkrepos"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + if output, err := runBundledScript(t, body, outputPath, callsPath, bin); err != nil { + t.Fatalf("%s: %v\n%s", tc.os, err, output) + } + calls, err := os.ReadFile(callsPath) + if err != nil { + t.Fatal(err) + } + if got := installRequests(string(calls)); !slices.Equal(got, tc.requests) { + t.Errorf("%s package requests = %v, want %v", tc.os, got, tc.requests) + } + outputs := parseOutputs(t, outputPath) + want := map[string]string{"compiler": "cc", "make": "make", "pkg_config": "pkg-config"} + for name, wantBase := range want { + if got := filepath.Base(outputs[name]); got != wantBase { + t.Errorf("%s: output %q = %q, want basename %q", tc.os, name, outputs[name], wantBase) + } + } + }) + } +} + +// installRequests returns the package sets the script asked for, in order. +func installRequests(calls string) []string { + var requests []string + for _, line := range strings.Split(strings.TrimSpace(calls), "\n") { + if after, ok := strings.CutPrefix(line, "install "); ok { + requests = append(requests, after) + } + } + return requests +} + +// dnf4 on AlmaLinux 9 and Rocky 9 rejects the development-tools group id and +// wants development instead. The script must try the second id rather than +// fail the recipe. +func TestBundledBuildDepsFallsBackToTheOtherGroupID(t *testing.T) { + body := bundledScript(t, "build-deps", "almalinux") + root := t.TempDir() + outputPath := filepath.Join(root, "output") + callsPath := filepath.Join(root, "calls") + bin := filepath.Join(root, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"cc", "make", "pkg-config"} { + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + installer := `stoat_pkg_install() { + printf 'install %s\n' "$*" >> "$STOAT_PKG_CALLS" + if [ "$1" = "@development-tools" ]; then + echo "Module or Group 'development-tools' is not available." >&2 + return 1 + fi +}` + if output, err := runBundledScriptWithInstaller(t, installer, body, outputPath, callsPath, bin); err != nil { + t.Fatalf("script failed where the group id was rejected: %v\n%s", err, output) + } + want := []string{"@development-tools", "@development", "pkgconf-pkg-config rpm-build"} + if got := installRequests(readFileString(t, callsPath)); !slices.Equal(got, want) { + t.Errorf("package requests = %v, want %v", got, want) + } +} + +func readFileString(t *testing.T, path string) string { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(body) +} + +// opensuse and the rpm family (fedora, almalinux, rocky) fall back to pkgconf +// when pkg-config is absent; every other family only ships pkg-config, so +// this is the only path that exercises the fallback. +func TestBundledBuildDepsPkgConfigFallsBackToPkgconf(t *testing.T) { + body := bundledScript(t, "build-deps", "fedora") + root := t.TempDir() + bin := filepath.Join(root, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"cc", "make", "pkgconf"} { + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + outputPath := filepath.Join(root, "output") + callsPath := filepath.Join(root, "calls") + if output, err := runBundledScript(t, body, outputPath, callsPath, bin); err != nil { + t.Fatalf("%v\n%s", err, output) + } + outputs := parseOutputs(t, outputPath) + if got := filepath.Base(outputs["pkg_config"]); got != "pkgconf" { + t.Errorf("pkg_config = %q, want basename %q", outputs["pkg_config"], "pkgconf") + } +} + +func TestBundledServiceToolsInstallsPerFamily(t *testing.T) { + cases := []struct { + os string + request string + }{ + {os: "ubuntu", request: "lsof strace procps"}, + {os: "debian", request: "lsof strace procps"}, + {os: "fedora", request: "lsof strace procps-ng"}, + {os: "almalinux", request: "lsof strace procps-ng"}, + {os: "rocky", request: "lsof strace procps-ng"}, + {os: "opensuse", request: "lsof strace procps"}, + {os: "arch", request: "lsof strace procps-ng"}, + {os: "alpine", request: "lsof strace procps openrc"}, + } + for _, tc := range cases { + for _, manager := range []string{"systemd", "openrc"} { + t.Run(tc.os+"/"+manager, func(t *testing.T) { + body := bundledScript(t, "service-tools", tc.os) + root := t.TempDir() + outputPath := filepath.Join(root, "output") + callsPath := filepath.Join(root, "calls") + bin := filepath.Join(root, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + fakeBin := "systemctl" + if manager == "openrc" { + fakeBin = "rc-status" + } + if err := os.WriteFile(filepath.Join(bin, fakeBin), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, "lsof"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, "strace"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + if tc.os == "alpine" { + if err := os.WriteFile(filepath.Join(bin, "setup-apkrepos"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + output, err := runBundledScript(t, body, outputPath, callsPath, bin) + if err != nil { + t.Fatalf("%s/%s: %v\n%s", tc.os, manager, err, output) + } + calls, err := os.ReadFile(callsPath) + if err != nil { + t.Fatal(err) + } + installRequests := 0 + for _, line := range strings.Split(strings.TrimSpace(string(calls)), "\n") { + if !strings.HasPrefix(line, "install ") { + continue + } + installRequests++ + if got := strings.TrimPrefix(line, "install "); got != tc.request { + t.Errorf("%s package request = %q, want %q", tc.os, got, tc.request) + } + } + if installRequests == 0 { + t.Errorf("%s: no captured package installation request", tc.os) + } + outputs := parseOutputs(t, outputPath) + if outputs["service_manager"] != manager { + t.Errorf("%s/%s: service_manager = %q, want %q", tc.os, manager, outputs["service_manager"], manager) + } + for _, name := range []string{"lsof", "strace"} { + if outputs[name] == "" { + t.Errorf("%s/%s: output %q is empty; outputs = %#v", tc.os, manager, name, outputs) + } + } + }) + } + } +} + +// Neither systemctl nor rc-status on PATH is a provisioning failure, not a +// silently empty service_manager output. +func TestBundledServiceToolsFailsWithoutKnownServiceManager(t *testing.T) { + body := bundledScript(t, "service-tools", "debian") + root := t.TempDir() + outputPath := filepath.Join(root, "output") + callsPath := filepath.Join(root, "calls") + bin := filepath.Join(root, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, "lsof"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, "strace"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + output, err := runBundledScript(t, body, outputPath, callsPath, bin) + if err == nil { + t.Fatalf("script succeeded without a service manager; output=%s", output) + } + want := "service-tools: no known service manager (systemd or openrc) was found" + if !strings.Contains(string(output), want) { + t.Errorf("error = %q, want it to contain %q", output, want) + } +} + +// PATH is set to exactly the fake bin dir the caller built, not extended with +// the host's real PATH: the test host may itself have a systemd, apt-get, dnf, +// or other real binary the recipe would find first and use instead of a fake. +func runBundledScript(t *testing.T, body, outputPath, callsPath, bin string) ([]byte, error) { + t.Helper() + return runBundledScriptWithInstaller(t, + `stoat_pkg_install() { printf 'install %s\n' "$*" >> "$STOAT_PKG_CALLS"; }`, + body, outputPath, callsPath, bin) +} + +// runBundledScriptWithInstaller lets one test supply its own stoat_pkg_install +// so a script that reacts to a failed package request can be exercised. +func runBundledScriptWithInstaller(t *testing.T, installer, body, outputPath, callsPath, bin string) ([]byte, error) { + t.Helper() + prefix := "stoat_pkg_setup() { printf 'setup\\n' >> \"$STOAT_PKG_CALLS\"; }\n" + installer + "\n" + cmd := exec.Command("sh", "-eu", "-c", prefix+body) + cmd.Env = append(os.Environ(), + "PATH="+bin, + "STOAT_OUTPUT="+outputPath, + "STOAT_PKG_CALLS="+callsPath, + ) + return cmd.CombinedOutput() +} + +func TestBundledPkgToolsInstallsPerFamily(t *testing.T) { + cases := []struct { + os string + request string + manager string + queryTool string + }{ + {os: "ubuntu", request: "apt-file dpkg-dev", manager: "apt-get", queryTool: "apt-file"}, + {os: "debian", request: "apt-file dpkg-dev", manager: "apt-get", queryTool: "apt-file"}, + {os: "fedora", request: "dnf-utils", manager: "dnf", queryTool: "repoquery"}, + {os: "almalinux", request: "dnf-utils", manager: "dnf", queryTool: "repoquery"}, + {os: "rocky", request: "dnf-utils", manager: "dnf", queryTool: "repoquery"}, + {os: "opensuse", request: "zypper libzypp", manager: "zypper", queryTool: "zypper"}, + {os: "arch", request: "pacman-contrib", manager: "pacman", queryTool: "pacman"}, + {os: "alpine", request: "apk-tools", manager: "apk", queryTool: "apk"}, + } + for _, tc := range cases { + t.Run(tc.os, func(t *testing.T) { + body := bundledScript(t, "pkg-tools", tc.os) + root := t.TempDir() + outputPath := filepath.Join(root, "output") + callsPath := filepath.Join(root, "calls") + bin := filepath.Join(root, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{tc.manager, tc.queryTool} { + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + if tc.os == "alpine" { + if err := os.WriteFile(filepath.Join(bin, "setup-apkrepos"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + if output, err := runBundledScript(t, body, outputPath, callsPath, bin); err != nil { + t.Fatalf("%s: %v\n%s", tc.os, err, output) + } + calls, err := os.ReadFile(callsPath) + if err != nil { + t.Fatal(err) + } + installRequests := 0 + for _, line := range strings.Split(strings.TrimSpace(string(calls)), "\n") { + if !strings.HasPrefix(line, "install ") { + continue + } + installRequests++ + if got := strings.TrimPrefix(line, "install "); got != tc.request { + t.Errorf("%s package request = %q, want %q", tc.os, got, tc.request) + } + } + if installRequests == 0 { + t.Errorf("%s: no captured package installation request", tc.os) + } + outputs := parseOutputs(t, outputPath) + if got := filepath.Base(outputs["manager"]); got != tc.manager { + t.Errorf("%s: manager = %q, want basename %q", tc.os, outputs["manager"], tc.manager) + } + if got := filepath.Base(outputs["query_tool"]); got != tc.queryTool { + t.Errorf("%s: query_tool = %q, want basename %q", tc.os, outputs["query_tool"], tc.queryTool) + } + }) + } +} + +// manager must resolve to the first of apt-get, dnf, zypper, pacman, apk +// found on PATH, in that order, regardless of which family script runs it. +// A hermetic PATH lets this also prove the negative: a manager earlier in +// the order is absent, so the next one wins. +func TestBundledPkgToolsManagerResolutionOrder(t *testing.T) { + cases := []struct { + name string + present []string + want string + }{ + {name: "apt-get over dnf", present: []string{"apt-get", "dnf"}, want: "apt-get"}, + {name: "dnf when apt-get absent", present: []string{"dnf"}, want: "dnf"}, + {name: "zypper when apt-get and dnf absent", present: []string{"zypper"}, want: "zypper"}, + {name: "pacman when zypper absent", present: []string{"pacman"}, want: "pacman"}, + {name: "apk when pacman absent", present: []string{"apk"}, want: "apk"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body := bundledScript(t, "pkg-tools", "fedora") + root := t.TempDir() + bin := filepath.Join(root, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range append(append([]string{}, tc.present...), "repoquery") { + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + outputPath := filepath.Join(root, "output") + callsPath := filepath.Join(root, "calls") + if output, err := runBundledScript(t, body, outputPath, callsPath, bin); err != nil { + t.Fatalf("%v\n%s", err, output) + } + outputs := parseOutputs(t, outputPath) + if got := filepath.Base(outputs["manager"]); got != tc.want { + t.Errorf("manager = %q, want %q", outputs["manager"], tc.want) + } + }) + } +} + func TestBundledPythonDevCreatesAndPreservesVenv(t *testing.T) { body := bundledScript(t, "python-dev", "alpine") account := currentTestAccount(t) diff --git a/internal/sshx/cloudinit_readiness_test.go b/internal/sshx/cloudinit_readiness_test.go index 89f5590b..70b1e784 100644 --- a/internal/sshx/cloudinit_readiness_test.go +++ b/internal/sshx/cloudinit_readiness_test.go @@ -62,8 +62,8 @@ func TestCloudInitWaitUsesUnprivilegedSSHThenEscalatesSetup(t *testing.T) { if strings.Contains(waitCall, "sudo") { t.Errorf("cloud-init wait escalated before the seeded user had sudo: %q", waitCall) } - if !strings.Contains(waitCall, "cloud-init status --wait --format json") { - t.Errorf("cloud-init wait argv = %q, want direct JSON status --wait", waitCall) + if !strings.Contains(waitCall, "cloud-init status --format json") { + t.Errorf("cloud-init wait argv = %q, want a direct JSON status probe", waitCall) } if !strings.Contains(calls[1], "sudo -n") { t.Errorf("package setup did not escalate after cloud-init completed: %q", calls[1]) @@ -90,11 +90,6 @@ func TestCloudInitReadinessRefusesBeforeFurtherProvisioning(t *testing.T) { status: `{"status":"error","extended_status":"error","errors":["write_files failed"]}`, exitCode: 1, }, - { - name: "non terminal status", - status: `{"status":"running","extended_status":"running","errors":[]}`, - exitCode: 0, - }, { name: "ssh transport failure", exitCode: 255, @@ -144,6 +139,104 @@ func TestCloudInitReadinessRefusesBeforeFurtherProvisioning(t *testing.T) { } } +// A running cloud-init is not a failure. Stoat waits for it and reports the +// last status it saw when its own deadline runs out. +func TestCloudInitRunningStatusWaitsUntilTheDeadline(t *testing.T) { + root := t.TempDir() + t.Setenv("STOAT_HOME", root) + writeCloudReadinessRecipe(t, root) + capture := installCloudInitSSH(t, `{"status":"running","extended_status":"running","errors":[]}`, 0) + port := acceptOnly(t, "SSH-2.0-OpenSSH_9.6\r\n") + vmDir := filepath.Join(root, "work") + if err := os.MkdirAll(vmDir, 0o755); err != nil { + t.Fatal(err) + } + v := &config.VM{ + Name: "work", Dir: vmDir, OS: "alpine", Backend: "cloudinit", + SSHUser: "stoat", SSHPort: port, Recipes: []string{"probe"}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err := Provision(ctx, v) + if err == nil { + t.Fatal("Provision() succeeded while cloud-init was still running") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("Provision() = %v, want the caller's deadline", err) + } + if calls := readCloudInitSSHCalls(t, capture); len(calls) == 0 { + t.Error("cloud-init was never probed") + } + log, err := os.ReadFile(v.ProvisionLogPath()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(log), RecipeMarker("probe")) { + t.Errorf("recipe ran while cloud-init was still running:\n%s", log) + } +} + +// cloud-init 25.3 keeps /run/cloud-init root-only, so the unprivileged probe +// returns a traceback and no JSON. Stoat retries that guest with escalation +// instead of waiting for an answer that never comes. +func TestCloudInitReadinessEscalatesWhenTheUnprivilegedProbeCannotRead(t *testing.T) { + root := t.TempDir() + t.Setenv("STOAT_HOME", root) + capture := installEscalationOnlyCloudInitSSH(t, + `{"status":"done","extended_status":"done","errors":[],"recoverable_errors":{}}`) + port := acceptOnly(t, "SSH-2.0-OpenSSH_9.6\r\n") + vmDir := filepath.Join(root, "work") + if err := os.MkdirAll(vmDir, 0o755); err != nil { + t.Fatal(err) + } + v := &config.VM{ + Name: "work", Dir: vmDir, OS: "alpine", Backend: "cloudinit", + SSHUser: "stoat", SSHPort: port, + } + + if err := Provision(context.Background(), v); err != nil { + t.Fatalf("Provision() = %v, want the escalated probe to satisfy readiness", err) + } + calls := readCloudInitSSHCalls(t, capture) + if len(calls) < 2 { + t.Fatalf("ssh calls = %v, want an unprivileged probe followed by an escalated one", calls) + } + if strings.Contains(calls[0], "sudo") { + t.Errorf("the first probe escalated: %q", calls[0]) + } + if !strings.Contains(calls[1], "sudo -n") || !strings.Contains(calls[1], "cloud-init status --format json") { + t.Errorf("the second probe = %q, want the same status command under sudo", calls[1]) + } +} + +// installEscalationOnlyCloudInitSSH answers the status probe with JSON only +// when the call escalates, and reproduces cloud-init's own failure otherwise: +// a traceback on stderr, nothing on stdout, exit 1. +func installEscalationOnlyCloudInitSSH(t *testing.T, status string) string { + t.Helper() + bin := t.TempDir() + capture := filepath.Join(bin, "calls.log") + script := "#!/bin/sh\n" + + "printf '%s\\n' \"$*\" >> " + cloudShellQuote(capture) + "\n" + + "case \"$*\" in\n" + + " *\"sudo -n cloud-init status\"*)\n" + + " printf '%s' " + cloudShellQuote(status) + "\n" + + " exit 0\n" + + " ;;\n" + + " *\"cloud-init status\"*)\n" + + " echo \"PermissionError: [Errno 13] Permission denied: '/run/cloud-init/cloud.cfg'\" >&2\n" + + " exit 1\n" + + " ;;\n" + + "esac\n" + + "exit 0\n" + if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + return capture +} + func TestCloudInitWaitCancellationKeepsContextErrorAndStopsChild(t *testing.T) { root := t.TempDir() t.Setenv("STOAT_HOME", root) diff --git a/internal/sshx/sshx.go b/internal/sshx/sshx.go index 20a40852..f0f09963 100644 --- a/internal/sshx/sshx.go +++ b/internal/sshx/sshx.go @@ -11,6 +11,7 @@ import ( "net" "os" "os/exec" + "slices" "sort" "strings" "syscall" @@ -255,49 +256,119 @@ func parseCloudInitStatus(body []byte) (cloudInitStatus, error) { return status, nil } -// waitCloudInit waits for cloud-init as the seeded SSH account. The account -// exists before cloud-init's package setup finishes, while its sudo package -// may not, so this command must not use escalate. Exit 2 is cloud-init's -// recoverable degraded result; the JSON still has to say done and list no hard -// errors before provisioning may continue. -func waitCloudInit(ctx context.Context, v *config.VM, log io.Writer) error { - var statusOut bytes.Buffer - ci := exec.CommandContext(ctx, "ssh", Args(v, "cloud-init", "status", "--wait", "--format", "json")...) - ci.Cancel = func() error { return ci.Process.Signal(syscall.SIGTERM) } - ci.WaitDelay = recipeShutdownGrace - ci.Stdout = &statusOut - ci.Stderr = log - - runErr := ci.Run() - if ctxErr := ctx.Err(); ctxErr != nil { - return ctxErr - } - exitCode := 0 - if runErr != nil { - var exitErr *exec.ExitError - if !errors.As(runErr, &exitErr) { - return fmt.Errorf("cloud-init readiness command failed: %w", runErr) - } - exitCode = exitErr.ExitCode() - if exitCode != 2 { - return fmt.Errorf("cloud-init readiness command exited with status %d", exitCode) +// cloudInitPollInterval spaces the readiness probes. `cloud-init status` +// answers at once, so the interval is the whole cost of a probe. +const cloudInitPollInterval = 5 * time.Second + +// cloudInitProbeTimeout bounds one probe. A guest that stops answering must +// not wedge the loop that owns the deadline. +const cloudInitProbeTimeout = time.Minute + +// cloudInitReadinessBudget bounds the wait when the caller gives no deadline. +// A cloud image that installs a large package set takes minutes, and every +// recipe in the seed runs before cloud-init reports done. +const cloudInitReadinessBudget = 30 * time.Minute + +// sshTransportExit is ssh's own failure code. The guest never returns it, so +// it means the connection failed rather than the command. +const sshTransportExit = 255 + +// cloudInitProbe asks the guest for cloud-init's status once. It asks as the +// seeded SSH account first, because that account exists before cloud-init +// installs the guest's sudo package. cloud-init 25.3 keeps /run/cloud-init at +// mode 0700, so the unprivileged answer there is a Python traceback and no +// JSON; the escalated retry covers that guest. The exit code carries meaning +// beside the JSON, so a body that parses wins over any status the command +// exited with. +func cloudInitProbe(ctx context.Context, v *config.VM, log io.Writer) (cloudInitStatus, int, error) { + attempt := func(argv []string) (cloudInitStatus, int, error) { + probeCtx, cancel := context.WithTimeout(ctx, cloudInitProbeTimeout) + defer cancel() + + var out bytes.Buffer + ci := exec.CommandContext(probeCtx, "ssh", Args(v, argv...)...) + ci.Cancel = func() error { return ci.Process.Signal(syscall.SIGTERM) } + ci.WaitDelay = recipeShutdownGrace + ci.Stdout = &out + ci.Stderr = log + + runErr := ci.Run() + if ctxErr := ctx.Err(); ctxErr != nil { + return cloudInitStatus{}, 0, ctxErr + } + exitCode := 0 + if runErr != nil { + var exitErr *exec.ExitError + if !errors.As(runErr, &exitErr) { + return cloudInitStatus{}, 0, fmt.Errorf("cloud-init readiness command failed: %w", runErr) + } + exitCode = exitErr.ExitCode() } + status, err := parseCloudInitStatus(out.Bytes()) + if err != nil { + return cloudInitStatus{}, exitCode, err + } + return status, exitCode, nil } - status, err := parseCloudInitStatus(statusOut.Bytes()) - if err != nil { - return err + remote := []string{"cloud-init", "status", "--format", "json"} + status, exitCode, err := attempt(remote) + if err == nil || ctx.Err() != nil || exitCode == sshTransportExit { + return status, exitCode, err } - if status.Status != "done" { - return fmt.Errorf("cloud-init is not ready: status %q", status.Status) + escalated := escalate(v, remote) + if slices.Equal(escalated, remote) { + return status, exitCode, err } - if len(status.Errors) > 0 { - return fmt.Errorf("cloud-init reported hard errors: %s", strings.Join(status.Errors, "; ")) + return attempt(escalated) +} + +// waitCloudInit waits for cloud-init to reach a terminal status. cloud-init's +// own `status --wait` cannot be trusted to return: on a guest whose run +// directory it has already made root-only, the unprivileged wait loop never +// observes the result. Stoat therefore owns the deadline and polls. +// +// Exit 2 is cloud-init's recoverable degraded result; the JSON still has to +// say done and list no hard errors before provisioning may continue. +func waitCloudInit(ctx context.Context, v *config.VM, log io.Writer) error { + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, cloudInitReadinessBudget) + defer cancel() } - if exitCode == 2 { - fmt.Fprintf(log, "cloud-init completed with recoverable warnings: %v\n", status.RecoverableErrors) + + last := "no answer yet" + for { + status, exitCode, err := cloudInitProbe(ctx, v, log) + switch { + case ctx.Err() != nil: + case exitCode == sshTransportExit: + return fmt.Errorf("cloud-init readiness command exited with status %d", exitCode) + case err != nil: + last = err.Error() + case status.Status == "error": + if len(status.Errors) > 0 { + return fmt.Errorf("cloud-init reported hard errors: %s", strings.Join(status.Errors, "; ")) + } + return fmt.Errorf("cloud-init is not ready: status %q", status.Status) + case status.Status == "done": + if len(status.Errors) > 0 { + return fmt.Errorf("cloud-init reported hard errors: %s", strings.Join(status.Errors, "; ")) + } + if exitCode == 2 { + fmt.Fprintf(log, "cloud-init completed with recoverable warnings: %v\n", status.RecoverableErrors) + } + return nil + default: + last = fmt.Sprintf("status %q", status.Status) + } + + select { + case <-ctx.Done(): + return fmt.Errorf("cloud-init did not report done (%s): %w", last, ctx.Err()) + case <-time.After(cloudInitPollInterval): + } } - return nil } // RunCheck runs one command inside v's guest through the guest prelude, as