7461802b62
prd-number-check / require-numbered-prds (pull_request) Successful in 9s
tracker-policy-pr / check-pr (pull_request) Successful in 12s
test / unit (pull_request) Successful in 1m3s
test / integration-docker (pull_request) Successful in 1m2s
test / image-input-builds (pull_request) Successful in 1m6s
test / coverage (pull_request) Failing after 20s
lint / lint (push) Successful in 3m30s
Two harness bugs, both of which made the last run lie about what it verified.
The run installed main. install.sh's default spec is the repo's default
branch, so `run` piped *this checkout's* installer into the throwaway user and
then had it install a package that does not contain the branch's changes —
which is why the doctor PermissionError fix appeared not to work: it was never
in the code under test. The clone line said so plainly (commit 420184b, not
this branch's HEAD) and I read past it. The spec now pins to the current
branch, over https because the throwaway user has neither our SSH key nor read
access to this mode-700 checkout. Since what it clones is whatever the remote
has, a dirty tree or an unpushed branch now prints a warning rather than
quietly testing something other than what is on disk.
The doctor probe also died with "sh: -c: line 1: syntax error: unexpected end
of file". `sudo -i` joins its argv into one string for the login shell's -c, so
an argument's quoting is not preserved and a newline terminates the command.
The multi-line snippet added in the previous commit could not survive that.
run_as_user now feeds snippets on stdin to `sh -s`, which is the same trick
install.sh already arrives by, and is immune to the joining. The install spec
moves into the stream as an export for the same reason — as an argument it had
the identical latent quoting bug, unnoticed only because the default spec has
no shell metacharacters in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
340 lines
14 KiB
Bash
Executable File
340 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Clean-install test harness for the macOS (Apple `container`) path.
|
|
#
|
|
# Exercises install.sh the way a brand-new user would, inside a throwaway
|
|
# macOS account you create and delete from the CLI. install.sh's entire
|
|
# footprint is user-home-local — the pipx venv under ~/.local, or the private
|
|
# venv at ~/.bot-bottle/venv plus a ~/.local/bin symlink, and the ~/.bot-bottle
|
|
# config dir. It writes no shell-profile PATH line, and never installs the
|
|
# backend (see
|
|
# the header of install.sh), so deleting the user is a complete,
|
|
# deterministic reset of everything the installer touched. The Apple
|
|
# `container` runtime is a HOST prerequisite installed once and kept;
|
|
# `deep-reset` is the rare escape hatch that also removes it.
|
|
#
|
|
# Why a throwaway user and not a disposable VM: bot-bottle's default macOS
|
|
# backend is Apple `container`, which runs each container in its own
|
|
# Virtualization.framework microVM. Running that backend inside a macOS
|
|
# guest VM needs nested virtualization, which Apple gates to M3+ silicon.
|
|
# On M1/M2 a separate user account is the only way to get a clean $HOME
|
|
# while still reaching the real host backend. Full rationale in
|
|
# docs/research/testing-clean-install-on-macos.md.
|
|
#
|
|
# Usage:
|
|
# sudo ./scripts/macos-install-test.sh test # up -> run -> status -> down
|
|
# sudo ./scripts/macos-install-test.sh up # create the throwaway user
|
|
# sudo ./scripts/macos-install-test.sh run # run install.sh (+doctor) as it
|
|
# ./scripts/macos-install-test.sh status # user present? backend ready?
|
|
# sudo ./scripts/macos-install-test.sh down # delete user + home (the reset)
|
|
# sudo ./scripts/macos-install-test.sh deep-reset # ALSO uninstall host `container`
|
|
#
|
|
# `test` is the one-shot clean cycle and the command you normally want: it
|
|
# refuses to start if the account already exists (a reused home is not a clean
|
|
# install), and it tears the account down on the way out however it exits, so
|
|
# a failed run never leaves an orphan behind. It exits non-zero if the install
|
|
# fails, if `bot-bottle` is missing from the new user's PATH, or if `doctor`
|
|
# reports unmet prerequisites — note install.sh itself exits 0 in that last
|
|
# case, so `test` is a stricter gate than running the installer by hand.
|
|
#
|
|
# Config via env:
|
|
# BB_TEST_USER account short name (default: bbtest)
|
|
# BB_TEST_FULLNAME account full name (default: "bot-bottle install test")
|
|
# BB_TEST_ADMIN 1=admin (reach container svc), 0=standard (default: 1)
|
|
# BB_TEST_INSTALL_URL curl this install.sh instead of piping the local checkout
|
|
# BB_TEST_KEEP 1=`test` skips its teardown, to poke at a failure
|
|
# BOT_BOTTLE_INSTALL_SPEC passed through to install.sh (pip / git spec)
|
|
#
|
|
# Notes:
|
|
# * Run from a normally-booted admin session. Grant Terminal *Full Disk
|
|
# Access* (System Settings -> Privacy & Security) or `down` half-fails
|
|
# with error -14120 and leaves an orphaned account.
|
|
# * `sysadminctl` always exits 0 even on failure, so `up`/`down` verify
|
|
# the result with `dscl` and fail loudly on a mismatch.
|
|
# * The account is created without a password: `run` drives it headlessly
|
|
# via `sudo -u`, which never needs the target's password. The account
|
|
# cannot GUI-login, which this harness does not require.
|
|
# * `run` covers the installer + `bot-bottle doctor`. Actually launching a
|
|
# bottle from the throwaway user may need a full launchd user session
|
|
# (`launchctl asuser`); on M1/M2 the backend can't run under nested virt
|
|
# anyway, so this harness stops at install + doctor.
|
|
|
|
set -euo pipefail
|
|
|
|
USER_NAME="${BB_TEST_USER:-bbtest}"
|
|
FULL_NAME="${BB_TEST_FULLNAME:-bot-bottle install test}"
|
|
ADMIN="${BB_TEST_ADMIN:-1}"
|
|
# https, not the ssh `origin`: the throwaway user has no key of ours.
|
|
BB_TEST_REPO_URL="${BB_TEST_REPO_URL:-https://gitea.dideric.is/didericis/bot-bottle.git}"
|
|
|
|
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
_REPO_ROOT="$(cd "$_SCRIPT_DIR/.." && pwd)"
|
|
|
|
# Set by `test`, which chains the steps itself and so suppresses the
|
|
# "here's the next command to run" hints the individual steps print.
|
|
IN_TEST=0
|
|
|
|
# --- guards ----------------------------------------------------------
|
|
require_macos() {
|
|
[ "$(uname -s)" = "Darwin" ] \
|
|
|| { echo "error: this harness is macOS-only (uname is $(uname -s))" >&2; exit 1; }
|
|
}
|
|
|
|
require_root() {
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
echo "error: '$1' needs root; re-run under sudo" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
user_exists() { dscl . -read "/Users/$USER_NAME" >/dev/null 2>&1; }
|
|
|
|
# Run a shell snippet as the throwaway user in a fresh login shell.
|
|
#
|
|
# The snippet goes in on stdin, never as an argument. `sudo -i` joins its argv
|
|
# into a single string and hands that to the login shell's -c, so quoting in an
|
|
# argument is not preserved and a newline ends the command outright ("sh: -c:
|
|
# line 1: syntax error: unexpected end of file"). Feeding `sh -s` from stdin
|
|
# sidesteps the joining entirely and lets snippets be multi-line.
|
|
run_as_user() { printf '%s\n' "$1" | sudo -u "$USER_NAME" -i sh -s; }
|
|
|
|
# `bot-bottle doctor` as the throwaway user. Non-zero when no entry point was
|
|
# installed at all, or when doctor itself is unhappy.
|
|
#
|
|
# Deliberately does NOT require `bot-bottle` on PATH: install.sh prints the
|
|
# PATH line rather than editing a shell profile, so on a fresh account the
|
|
# entry point is installed and working but not on PATH. Demanding PATH here
|
|
# would fail every run for a reason the installer intends.
|
|
doctor_as_user() {
|
|
# shellcheck disable=SC2016 # $HOME/$bb must expand in the *target* user's
|
|
# shell, not in this one — that's the whole point of the single quotes.
|
|
run_as_user '
|
|
for bb in bot-bottle "$HOME/.local/bin/bot-bottle" "$HOME/.bot-bottle/venv/bin/bot-bottle"; do
|
|
if command -v "$bb" >/dev/null 2>&1; then
|
|
case "$bb" in
|
|
bot-bottle) : ;;
|
|
*) echo " (not on PATH — running $bb directly, as install.sh advises)" ;;
|
|
esac
|
|
exec "$bb" doctor
|
|
fi
|
|
done
|
|
echo " no bot-bottle entry point found for this user" >&2
|
|
exit 1
|
|
'
|
|
}
|
|
|
|
# --- commands --------------------------------------------------------
|
|
cmd_up() {
|
|
require_macos
|
|
require_root up
|
|
if user_exists; then
|
|
echo "$USER_NAME already exists; nothing to do (run 'down' first to reset)"
|
|
return 0
|
|
fi
|
|
local admin_flag=()
|
|
[ "$ADMIN" = "1" ] && admin_flag=(-admin)
|
|
# No -password: the account is only ever driven headlessly via `sudo -u`,
|
|
# which doesn't need one. sysadminctl warns about FileVault here; that's
|
|
# irrelevant to a headless test account.
|
|
sysadminctl -addUser "$USER_NAME" -fullName "$FULL_NAME" "${admin_flag[@]}" || true
|
|
# sysadminctl exits 0 regardless of outcome, so confirm the account landed.
|
|
user_exists || { echo "error: failed to create $USER_NAME" >&2; return 1; }
|
|
if [ "$IN_TEST" = 1 ]; then
|
|
echo "created $USER_NAME (admin=$ADMIN)"
|
|
else
|
|
echo "created $USER_NAME (admin=$ADMIN). Install into it with: sudo $0 run"
|
|
fi
|
|
}
|
|
|
|
# The package spec to install. install.sh's own default is the repo's DEFAULT
|
|
# BRANCH, which would mean piping this checkout's installer into the throwaway
|
|
# user and then installing main's code — verifying the PR's install.sh against
|
|
# a package that doesn't contain the PR. Pin to the branch under test instead.
|
|
#
|
|
# The branch has to be pushed: the throwaway user clones over https and cannot
|
|
# read this checkout (mode-700 home, and no SSH key for the ssh remote), so
|
|
# what it gets is whatever the remote has. Say so when that differs from here.
|
|
resolve_install_spec() {
|
|
if [ -n "${BOT_BOTTLE_INSTALL_SPEC:-}" ]; then
|
|
echo "$BOT_BOTTLE_INSTALL_SPEC"
|
|
return 0
|
|
fi
|
|
local branch
|
|
branch="$(git -C "$_REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null)" || branch=""
|
|
if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then
|
|
echo "note: not on a named branch; installing the repo default" >&2
|
|
echo "git+${BB_TEST_REPO_URL}"
|
|
return 0
|
|
fi
|
|
if [ -n "$(git -C "$_REPO_ROOT" status --porcelain 2>/dev/null)" ]; then
|
|
echo "warning: working tree is dirty — the throwaway user installs" >&2
|
|
echo " $branch as pushed, not what is on disk here." >&2
|
|
elif ! git -C "$_REPO_ROOT" diff --quiet "@{upstream}" 2>/dev/null; then
|
|
echo "warning: $branch differs from its upstream — push first, or the" >&2
|
|
echo " throwaway user installs stale code." >&2
|
|
fi
|
|
echo "git+${BB_TEST_REPO_URL}@${branch}"
|
|
}
|
|
|
|
cmd_run() {
|
|
require_macos
|
|
require_root run
|
|
user_exists || { echo "error: $USER_NAME does not exist; run 'sudo $0 up' first" >&2; return 1; }
|
|
|
|
local spec
|
|
spec="$(resolve_install_spec)"
|
|
|
|
echo "== installing bot-bottle as $USER_NAME =="
|
|
echo "== spec: $spec =="
|
|
if [ -n "${BB_TEST_INSTALL_URL:-}" ]; then
|
|
run_as_user "BOT_BOTTLE_INSTALL_SPEC='$spec' curl -fsSL '$BB_TEST_INSTALL_URL' | sh"
|
|
else
|
|
# Test THIS checkout's install.sh, not the published one, so a PR is
|
|
# verifiable before it lands. Feed it in on stdin rather than staging a
|
|
# copy somewhere the throwaway user can read: the redirect is opened by
|
|
# root before sudo drops privileges, so the tester's mode-700 home is a
|
|
# non-issue, there's no temp file to leak if the run is interrupted, and
|
|
# `sh -s` is the same shape as the documented `curl … | sh` install.
|
|
#
|
|
# The spec is prepended to the stream as an export rather than passed
|
|
# as an argument, for the same argv-joining reason as run_as_user.
|
|
{
|
|
printf "export BOT_BOTTLE_INSTALL_SPEC='%s'\n" "$spec"
|
|
cat "$_REPO_ROOT/install.sh"
|
|
} | sudo -u "$USER_NAME" -i sh -s
|
|
fi
|
|
[ "$IN_TEST" = 1 ] \
|
|
|| echo "== install.sh runs 'doctor' itself; re-check anytime with: $0 status =="
|
|
}
|
|
|
|
# Informational, with one teeth-bearing case: when it can actually reach
|
|
# doctor (root, account present) its exit status is doctor's, so `test` and
|
|
# any other caller can use it as the post-install assertion.
|
|
cmd_status() {
|
|
require_macos
|
|
local rc=0
|
|
if user_exists; then
|
|
echo "user: $USER_NAME present"
|
|
if [ "$(id -u)" -eq 0 ]; then
|
|
echo "doctor (as $USER_NAME):"
|
|
doctor_as_user || rc=1
|
|
else
|
|
echo " (re-run under sudo to run 'bot-bottle doctor' as $USER_NAME)"
|
|
fi
|
|
else
|
|
echo "user: $USER_NAME absent"
|
|
fi
|
|
if command -v container >/dev/null 2>&1; then
|
|
echo "backend: apple 'container' present ($(container --version 2>/dev/null | head -1))"
|
|
else
|
|
echo "backend: apple 'container' NOT on PATH (host prerequisite; install once)"
|
|
fi
|
|
return "$rc"
|
|
}
|
|
|
|
cmd_down() {
|
|
require_macos
|
|
require_root down
|
|
if ! user_exists; then
|
|
echo "$USER_NAME not present; nothing to remove"
|
|
return 0
|
|
fi
|
|
# A plain -deleteUser removes the home dir, which is the whole reset.
|
|
# -secure is a no-op on modern macOS (secure erase of the home folder
|
|
# was removed in Sierra), so it buys nothing here.
|
|
sysadminctl -deleteUser "$USER_NAME" || true
|
|
if user_exists; then
|
|
echo "error: $USER_NAME still present after delete." >&2
|
|
echo " - grant Terminal Full Disk Access (System Settings > Privacy & Security), or" >&2
|
|
echo " - it may hold the last Secure Token (won't happen while another admin exists)" >&2
|
|
return 1
|
|
fi
|
|
echo "removed $USER_NAME and its home — install surface is clean."
|
|
}
|
|
|
|
# Teardown half of `test`, installed as an EXIT trap the moment the account
|
|
# exists so that a failure — or a Ctrl-C — still leaves the machine clean.
|
|
_test_teardown() {
|
|
local rc=$?
|
|
trap - EXIT INT TERM
|
|
if [ "${BB_TEST_KEEP:-0}" = "1" ]; then
|
|
echo
|
|
echo "== [4/4] down: SKIPPED (BB_TEST_KEEP=1) =="
|
|
echo " $USER_NAME is still around; remove it with: sudo $0 down"
|
|
exit "$rc"
|
|
fi
|
|
echo
|
|
echo "== [4/4] down =="
|
|
cmd_down || rc=1
|
|
if [ "$rc" -eq 0 ]; then
|
|
echo
|
|
echo "PASS: a brand-new user can install bot-bottle and pass doctor."
|
|
else
|
|
echo
|
|
echo "FAIL: see above (the throwaway account was torn down regardless)." >&2
|
|
fi
|
|
exit "$rc"
|
|
}
|
|
|
|
cmd_test() {
|
|
require_macos
|
|
require_root test
|
|
# A pre-existing account means a pre-existing home, which is the one thing
|
|
# this harness exists to rule out. Don't silently test a dirty install.
|
|
if user_exists; then
|
|
echo "error: $USER_NAME already exists, so this would not be a clean install." >&2
|
|
echo " reset first: sudo $0 down" >&2
|
|
return 1
|
|
fi
|
|
IN_TEST=1
|
|
|
|
echo "== [1/4] up =="
|
|
cmd_up
|
|
trap _test_teardown EXIT INT TERM
|
|
|
|
echo
|
|
echo "== [2/4] run =="
|
|
cmd_run
|
|
|
|
echo
|
|
echo "== [3/4] status =="
|
|
# install.sh exits 0 even when doctor reports unmet prerequisites, so the
|
|
# install succeeding is not the verdict — this is.
|
|
cmd_status || {
|
|
echo "error: doctor is unhappy for a freshly installed user (see above)." >&2
|
|
echo " re-run with BB_TEST_KEEP=1 to keep $USER_NAME around and dig in." >&2
|
|
return 1
|
|
}
|
|
}
|
|
|
|
cmd_deep_reset() {
|
|
require_macos
|
|
require_root deep-reset
|
|
# Remove the user first (idempotent), then the HOST-level container
|
|
# runtime that a user deletion leaves behind under /usr/local + launchd.
|
|
cmd_down || true
|
|
if command -v container >/dev/null 2>&1; then
|
|
# The service can run in more than one launchd context (the invoking
|
|
# user's and root's), so stop both, best-effort.
|
|
[ -n "${SUDO_USER:-}" ] && sudo -u "$SUDO_USER" container system stop 2>/dev/null || true
|
|
container system stop 2>/dev/null || true
|
|
if [ -x /usr/local/bin/uninstall-container.sh ]; then
|
|
/usr/local/bin/uninstall-container.sh -d || true
|
|
echo "uninstalled the host Apple 'container' runtime"
|
|
else
|
|
echo "note: /usr/local/bin/uninstall-container.sh not found; runtime left as-is" >&2
|
|
fi
|
|
else
|
|
echo "no 'container' runtime on PATH; nothing further to remove"
|
|
fi
|
|
}
|
|
|
|
case "${1:-}" in
|
|
test) cmd_test ;;
|
|
up) cmd_up ;;
|
|
run) cmd_run ;;
|
|
status) cmd_status ;;
|
|
down) cmd_down ;;
|
|
deep-reset) cmd_deep_reset ;;
|
|
*) echo "usage: $0 {test|up|run|status|down|deep-reset}" >&2 ; exit 2 ;;
|
|
esac
|