90d6104e17
prd-number-check / require-numbered-prds (pull_request) Successful in 5s
test / image-input-builds (pull_request) Successful in 38s
test / unit (pull_request) Successful in 44s
tracker-policy-pr / check-pr (pull_request) Successful in 4s
lint / lint (push) Successful in 53s
test / integration-docker (pull_request) Successful in 58s
test / coverage (pull_request) Successful in 18s
The harness's second run hit the wall the first one predicted: a fresh account has no pipx, so install.sh fell to `pip install --user`, and every Python a Mac offers — Homebrew and python.org alike — is externally managed, so PEP 668 blocked it. That fallback was never a fallback on macOS; it was a dead end that printed instructions. Replace it with a venv at ~/.bot-bottle/venv (BOT_BOTTLE_VENV to move it), with the console script symlinked into ~/.local/bin. PEP 668 does not apply inside a venv, and venv is stdlib, so unlike pipx there is nothing to bootstrap first. pipx stays the preferred path when present, so anyone already managing their Python apps that way is unaffected — and the post-install PATH check now asks pipx for PIPX_BIN_DIR instead of assuming ~/.local/bin. Keeping the venv under ~/.bot-bottle rather than ~/.local/share means the whole footprint stays in one directory, which is what lets the throwaway-account teardown remain a complete reset. This removes the PEP 668 pre-flight and the sysconfig user-scheme lookup, both of which existed only to serve the --user path. Their tests go with them: * `detects_externally_managed_python` asserted the check that is now moot; replaced by one asserting pipx is still preferred when present. * `checks_pip_usable_before_fallback` pinned a pip probe that no longer runs; replaced by one asserting the venv's own pip does the install, since using the base interpreter's would install outside the venv. * `resolves_user_scripts_dir_not_hardcoded` and `macos_user_scheme_is_not_dot_local_bin` guarded the ~/Library/Python scripts-dir lookup. Nothing installs there now. The surviving "don't hardcode" concern is pipx's bin dir, which has its own test. Five tests are added for the new path: the venv fallback exists, no --user path survives, the venv is under the config dir, venv creation failure names python3-venv (Debian ships it separately), and the entry point is exposed outside the venv. Verified end to end in a sandbox HOME with a fresh-account PATH and no pipx: venv built, package installed, symlink created, `doctor` reached and green (python 3.14.5, macos-container ready), exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
281 lines
11 KiB
Bash
Executable File
281 lines
11 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}"
|
|
|
|
_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.
|
|
run_as_user() { sudo -u "$USER_NAME" -i sh -c "$1"; }
|
|
|
|
# `bot-bottle doctor` as the throwaway user. Non-zero when the entry point
|
|
# never made it onto that user's PATH, or when doctor itself is unhappy.
|
|
doctor_as_user() {
|
|
if ! run_as_user 'command -v bot-bottle >/dev/null 2>&1'; then
|
|
echo " bot-bottle is not on PATH for $USER_NAME"
|
|
return 1
|
|
fi
|
|
run_as_user 'bot-bottle doctor'
|
|
}
|
|
|
|
# --- 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
|
|
}
|
|
|
|
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_env=""
|
|
[ -n "${BOT_BOTTLE_INSTALL_SPEC:-}" ] \
|
|
&& spec_env="BOT_BOTTLE_INSTALL_SPEC='$BOT_BOTTLE_INSTALL_SPEC' "
|
|
|
|
echo "== installing bot-bottle as $USER_NAME =="
|
|
if [ -n "${BB_TEST_INSTALL_URL:-}" ]; then
|
|
run_as_user "curl -fsSL '$BB_TEST_INSTALL_URL' | ${spec_env}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.
|
|
run_as_user "${spec_env}sh -s" < "$_REPO_ROOT/install.sh"
|
|
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
|