Merge pull request 'fix(launcher): run on a bare Alpine host (POSIX prologue, self-installs bash)' (#6) from fix/alpine-posix-launcher into main

Reviewed-on: #6
This commit is contained in:
2026-08-10 22:18:56 +00:00
3 changed files with 124 additions and 42 deletions
+13 -4
View File
@@ -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.
+83 -28
View File
@@ -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/<name>/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 <command> [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
+28 -10
View File
@@ -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 <url>/automations-bundle.sh && bash automations-bundle.sh" >&2
echo " curl -fsSLO <url>/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