From f71f8d615acde417ae9dd525ccb238c9bd64515a Mon Sep 17 00:00:00 2001 From: William Gill Date: Mon, 10 Aug 2026 17:13:30 -0500 Subject: [PATCH] fix(launcher): run on a bare Alpine host (POSIX prologue, self-installs bash) automations.sh was #!/usr/bin/env bash and written in bash, but a stock Alpine box has busybox ash and no bash at all -- so the launcher died before it could install anything: ./automations.sh -> env: 'bash': No such file or directory curl ... | bash -> bash: not found sh automations.sh -> syntax errors cloud-init/base.yml already installs bash/git/curl before touching the repo; the launcher never got the same treatment. Give it a #!/bin/sh shebang and a strictly-POSIX prologue that ash can parse: self-locate via ${0%/*} (busybox dirname takes `--` as the filename, so `dirname -- "$0"` would answer "."), clone on the piped path as before, install git/bash via apk/apt-get/dnf/yum, then exec bash on this same file. The bash pass skips the prologue via BASH_VERSION, and an exported _AUTOMATIONS_REEXEC guard rules out an exec loop. Everything below the handoff is the unchanged bash launcher. The generated bundle stub had the identical bug -- it's the other fresh-host entry point -- so make it POSIX sh too and have it install bash before extracting. Docs: the one-liner now pipes to `sh`, bundle examples use `sh`. Still bash-only on a bare Alpine host: the scp'd deployments/*/deploy.sh artifacts. Co-Authored-By: Claude Opus 5 --- README.md | 17 ++++++-- automations.sh | 111 ++++++++++++++++++++++++++++++++++++------------ build-bundle.sh | 38 ++++++++++++----- 3 files changed, 124 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index f338a18..f6e5853 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ with automatic Let's Encrypt TLS, orchestrated with Docker Compose. ```bash curl -fsSL https://git.anomalous.dev/57_Wolve/automations/raw/branch/main/automations.sh \ - | REPO_URL=https://git.anomalous.dev/57_Wolve/automations.git bash + | REPO_URL=https://git.anomalous.dev/57_Wolve/automations.git sh ``` Or, from a clone: @@ -27,6 +27,12 @@ Or, from a clone: ./automations.sh ``` +Pipe it to `sh`, not `bash` — a stock Alpine box has busybox `ash` and no bash +at all. The launcher's prologue is POSIX sh: it installs `git`/`bash` for the +distro it finds itself on (apk / apt-get / dnf / yum), then re-execs itself +under bash. `./automations.sh` from a clone does the same, so a bare Alpine +install needs nothing prepared by hand. + [`automations.sh`](automations.sh) opens a **Gum** wizard (auto-installed) that lets you: @@ -55,11 +61,14 @@ pipe, so download first): ```bash curl -fsSLO https://your-host/automations-bundle.sh -bash automations-bundle.sh # launcher wizard -bash automations-bundle.sh bash scripts/setup-host.sh # run one script -SSH_PORT=2222 bash automations-bundle.sh bash scripts/harden-jumphost.sh +sh automations-bundle.sh # launcher wizard +sh automations-bundle.sh bash scripts/setup-host.sh # run one script +SSH_PORT=2222 sh automations-bundle.sh bash scripts/harden-jumphost.sh ``` +Like the launcher, the bundle's stub is POSIX sh and installs `bash` if the +host lacks it, so it runs on a bare Alpine box. + It extracts to `INSTALL_DIR` (default `/opt/automations`) and runs the launcher or the command you pass. The payload excludes ignored files, so no secrets are embedded. diff --git a/automations.sh b/automations.sh index c939c5f..aa61a0a 100644 --- a/automations.sh +++ b/automations.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/sh # # automations.sh -- one command to run or deploy anything in this repo. # @@ -6,7 +6,7 @@ # # 1. One-liner on a fresh target host (clones the repo, then launches): # curl -fsSL https://git.anomalous.dev/57_Wolve/automations/raw/branch/main/automations.sh \ -# | REPO_URL=https://git.anomalous.dev/57_Wolve/automations.git bash +# | REPO_URL=https://git.anomalous.dev/57_Wolve/automations.git sh # # 2. From a clone: # ./automations.sh @@ -22,43 +22,98 @@ # choices in, or just call the underlying deployments//deploy.sh # directly -- they all honor SKIP_PROMPTS=1. -set -euo pipefail +# ============================================================================ +# PROLOGUE -- POSIX sh only. Everything below the "exec bash" handoff is bash. +# +# The shebang is /bin/sh, not bash, on purpose: a stock Alpine box has busybox +# ash and NO bash at all, so a `#!/usr/bin/env bash` launcher dies before it +# can install anything ("env: 'bash': No such file or directory"). This part +# therefore has to parse and run under ash: no [[ ]], no arrays, no +# BASH_SOURCE, no printf -v. It locates (or clones) the repo, makes sure bash +# exists, and re-execs this same file under bash -- which then skips the +# prologue via BASH_VERSION and runs the real launcher. +# ============================================================================ +set -eu + +_boot_log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; } +_boot_die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; } + +# Install packages with whichever manager this distro has -- apk (Alpine), +# apt-get (Debian/Ubuntu), dnf/yum (Alma/RHEL). oslib.sh's pkg_install can't +# help here: it's bash, and on the piped path it isn't even on disk yet. +_boot_install() { + if command -v apk >/dev/null 2>&1; then + apk add -q "$@" 2>/dev/null && return 0 + apk update -q >/dev/null 2>&1 || true # stale/absent index on a fresh box + apk add -q "$@" + elif command -v apt-get >/dev/null 2>&1; then + apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" + elif command -v dnf >/dev/null 2>&1; then + dnf install -y -q "$@" + elif command -v yum >/dev/null 2>&1; then + yum install -y -q "$@" + else + return 1 + fi +} + +_boot_need() { # _boot_need [package] -> 0 if it's available afterwards + if command -v "$1" >/dev/null 2>&1; then return 0; fi + _boot_log "$1 not found; installing it..." + _boot_install "${2:-$1}" || true + command -v "$1" >/dev/null 2>&1 +} # ---------------------------------------------------------------------------- # Self-locate, or bootstrap by cloning the repo (one-liner / piped form). # ---------------------------------------------------------------------------- -_self="${BASH_SOURCE[0]:-}" -if [[ -n "$_self" && -f "$(cd "$(dirname "$_self")" 2>/dev/null && pwd)/scripts/lib.sh" ]]; then - ROOT="$(cd "$(dirname "$_self")" && pwd)" +ROOT="" +# Strip the last path component ourselves rather than calling dirname: busybox +# dirname takes its first argument literally, so `dirname -- "$0"` would answer +# "." on Alpine. `$0` is "sh"/"bash" (no slash) when we're piped from curl. +case "$0" in + */*) _dir="${0%/*}" ;; + *) _dir="." ;; +esac +_dir="$(CDPATH= cd "$_dir" 2>/dev/null && pwd)" || _dir="" +if [ -n "$_dir" ] && [ -f "$_dir/scripts/lib.sh" ]; then + ROOT="$_dir" else - # Piped via curl: we don't have the repo on disk. Clone it, then re-exec. + # Piped via curl: we don't have the repo on disk. Clone it, then hand off. : "${REPO_URL:=}" : "${REPO_BRANCH:=main}" - [[ -n "$REPO_URL" ]] || { - echo "[x] Running standalone (piped). Set REPO_URL=... so I can clone the repo." >&2 - exit 1 - } - # We need git to clone, but oslib.sh's pkg_install isn't on disk yet (that's - # what we're cloning). Install git inline across the supported package - # managers -- apk (Alpine), apt-get (Debian/Ubuntu), dnf/yum (Alma/RHEL). - if ! command -v git >/dev/null 2>&1; then - echo "[+] git not found; installing it..." >&2 - if command -v apk >/dev/null 2>&1; then apk add -q git || true - elif command -v apt-get >/dev/null 2>&1; then { apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git; } || true - elif command -v dnf >/dev/null 2>&1; then dnf install -y -q git || true - elif command -v yum >/dev/null 2>&1; then yum install -y -q git || true - fi - fi - command -v git >/dev/null 2>&1 || { - echo "[x] git is required to clone the repo, but it isn't installed and I couldn't install it automatically (need root + a supported package manager). Install git, then re-run." >&2 - exit 1 - } + [ -n "$REPO_URL" ] || _boot_die "Running standalone (piped). Set REPO_URL=... so I can clone the repo." + _boot_need git || _boot_die "git is required to clone the repo, but it isn't installed and I couldn't install it automatically (need root + a supported package manager). Install git, then re-run." _tmp="$(mktemp -d -t automations.XXXXXX)" - echo "[+] Cloning $REPO_URL ($REPO_BRANCH)..." + _boot_log "Cloning $REPO_URL ($REPO_BRANCH)..." git clone --depth 1 --branch "$REPO_BRANCH" "$REPO_URL" "$_tmp" - exec bash "$_tmp/automations.sh" "$@" + ROOT="$_tmp" fi +# ---------------------------------------------------------------------------- +# Hand off to bash. Needed when we're running under ash/dash, and when the body +# we want is the freshly cloned copy rather than the piped stdin we came from. +# ---------------------------------------------------------------------------- +if [ -z "${BASH_VERSION:-}" ] || [ ! -f "$0" ] || [ "$ROOT" != "$_dir" ]; then + if [ "${_AUTOMATIONS_REEXEC:-0}" = 1 ]; then + # Already handed off once. If we're in bash the handoff worked and only + # the path comparison differs (symlinked checkout) -- just continue. + [ -n "${BASH_VERSION:-}" ] || _boot_die "Re-exec under bash did not take effect. Run it explicitly: bash $ROOT/automations.sh" + else + # The launcher, everything it sources (scripts/lib.sh, scripts/oslib.sh), + # and every deploy.sh it invokes are bash. Alpine images routinely ship + # without it, so install it before going any further. + _boot_need bash || _boot_die "bash is required, but it isn't installed and I couldn't install it automatically (need root + a supported package manager). Install bash, then re-run." + _AUTOMATIONS_REEXEC=1; export _AUTOMATIONS_REEXEC + exec bash "$ROOT/automations.sh" "$@" + fi +fi + +# ============================================================================ +# Running under bash from here down. +# ============================================================================ +set -euo pipefail + # shellcheck source=scripts/lib.sh . "$ROOT/scripts/lib.sh" load_globals diff --git a/build-bundle.sh b/build-bundle.sh index ca786c9..90b3ca4 100644 --- a/build-bundle.sh +++ b/build-bundle.sh @@ -49,34 +49,52 @@ PAYLOAD="$(make_tar | base64 | tr -d '\n')" # --------------------------------------------------------------------------- { cat <<'STUB' -#!/usr/bin/env bash +#!/bin/sh # # automations-bundle.sh -- self-extracting bundle of the automations repo. # Generated by build-bundle.sh. Download, then run (it can't extract from a # pipe -- it needs to read itself as a file): # # curl -fsSLO https://your-host/automations-bundle.sh -# bash automations-bundle.sh # launcher wizard -# bash automations-bundle.sh bash scripts/setup-host.sh # run a script -# SSH_PORT=2222 bash automations-bundle.sh bash scripts/harden-jumphost.sh +# sh automations-bundle.sh # launcher wizard +# sh automations-bundle.sh bash scripts/setup-host.sh # run a script +# SSH_PORT=2222 sh automations-bundle.sh bash scripts/harden-jumphost.sh +# +# POSIX sh, not bash: a stock Alpine host has busybox ash and no bash, so a +# bash stub would fail before it could install anything. It installs bash +# itself (everything inside the bundle is bash) and then runs the launcher. # # Env: # INSTALL_DIR where to extract (default /opt/automations) # BUNDLE_KEEP 1 to keep the extracted repo (default), 0 to use a temp dir # and remove it after the command finishes -set -euo pipefail +set -eu : "${INSTALL_DIR:=/opt/automations}" : "${BUNDLE_KEEP:=1}" -SELF="${BASH_SOURCE[0]:-$0}" -if [[ ! -f "$SELF" ]]; then +SELF="$0" +if [ ! -f "$SELF" ]; then echo "[x] Run me as a downloaded file, not via a pipe:" >&2 - echo " curl -fsSLO /automations-bundle.sh && bash automations-bundle.sh" >&2 + echo " curl -fsSLO /automations-bundle.sh && sh automations-bundle.sh" >&2 exit 1 fi -if [[ "$BUNDLE_KEEP" != "1" ]]; then +# The repo contents -- launcher, scripts/, deployments/ -- are all bash. +if ! command -v bash >/dev/null 2>&1; then + echo "[+] bash not found; installing it..." >&2 + if command -v apk >/dev/null 2>&1; then { apk add -q bash 2>/dev/null || { apk update -q && apk add -q bash; }; } || true + elif command -v apt-get >/dev/null 2>&1; then { apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq bash; } || true + elif command -v dnf >/dev/null 2>&1; then dnf install -y -q bash || true + elif command -v yum >/dev/null 2>&1; then yum install -y -q bash || true + fi + command -v bash >/dev/null 2>&1 || { + echo "[x] bash is required, but it isn't installed and I couldn't install it automatically (need root + a supported package manager). Install bash, then re-run." >&2 + exit 1 + } +fi + +if [ "$BUNDLE_KEEP" != "1" ]; then INSTALL_DIR="$(mktemp -d -t automations.XXXXXX)" trap 'rm -rf "$INSTALL_DIR"' EXIT fi @@ -88,7 +106,7 @@ sed -e '1,/^__ARCHIVE_BELOW__$/d' "$SELF" | base64 -d | tar -xz -C "$INSTALL_DIR cd "$INSTALL_DIR" chmod +x automations.sh build-bundle.sh scripts/*.sh deployments/*/*.sh 2>/dev/null || true -if [[ "$#" -gt 0 ]]; then +if [ "$#" -gt 0 ]; then exec "$@" else exec bash ./automations.sh