feat: add macOS clean-install test harness #529
Reference in New Issue
Block a user
Delete Branch "feat/macos-install-test-harness"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
scripts/macos-install-test.sh, a throwaway-user harness that exercisesinstall.shthe way a brand-new user would on macOS, plus the research note that motivates it — and the installer fixes the harness found by being run.Why a throwaway user (not a disposable VM)
bot-bottle's default macOS backend is Apple
container, which runs each container in its ownVirtualization.frameworkmicroVM. Running that inside a macOS guest VM needs nested virtualization, gated to M3+ silicon, so on M1/M2 a VM can't reach the real backend. A separate account is the only way to get a clean$HOMEwhile still exercising the host backend.Two variants
Because "does the installer work" and "can a new user actually run a bottle" are different questions:
testtest-ready"Without prerequisites" isn't artificial — it's the default state of every new account, since the
containerservice is per-user. Both pass.Both refuse to start against an existing account (a reused home isn't a clean install) and tear it down from an
EXIT/INTtrap armed the moment it exists, so a failure or Ctrl-C never orphans an account.install.shexits 0 whendoctorreports unmet prerequisites, so either variant is a stricter gate than running the installer by hand.Other subcommands:
up,run,prereqs,status,down,deep-reset.What running it found
The harness paid for itself — each of these was found by an actual run, not by reading code:
doctorcrashed withPermissionError: [Errno 13] Permission denied: 'ip'. The netpool probes caught onlyFileNotFoundError, but a name on PATH that the user can't execute raisesPermissionError, and CPython reports that EACCES in preference to the ENOENT from other PATH entries. Widened toOSErrorthere and in the two docker probes. Not macOS-specific — any user with a non-executableip/nft/dockeron PATH hit it.install.shdead-ended on macOS. A fresh account resolvespython3to/usr/bin/python3(3.9.6) becausepath_helperputs/etc/pathsahead of/etc/paths.d. It now searches versioned names and known locations, threads the chosen interpreter through everything downstream, and gives an actionable error.pip install --userwas never a fallback on macOS — PEP 668 blocks it on every interpreter a Mac offers. Replaced with a private venv at~/.bot-bottle/venv.containerprerequisite is two steps, not one: start the per-user service and install a guest kernel.container system startonly prompts for the kernel, so headless callers need--enable-kernel-install.Known, unfixed:
doctortells installed users to run./cli.py backend setup, which doesn't exist outside a checkout.Notes
sysadminctlalways exits 0, soup/downverify withdscl.downhalf-fails with error -14120.launchctl asuser <uid>; thecontainerapiserver is a per-user launchd agent reached over XPC, and plainsudo -uleaves the caller in root's bootstrap namespace. Falls back to plainsudo -uwhen no domain exists.test-readydownloads a guest kernel per run (the previous run's went with the deleted home);BB_TEST_KERNEL_INSTALL=0skips it.uname), so Linux CI doesn't exercise it. Covered by a stubbed scenario suite during development: 23 scenarios including teardown-on-Ctrl-C, the two variants' differing verdicts, and the no-launchd-session fallback.🤖 Generated with Claude Code
testcycle to the macOS install harness d415581adcThe install itself now works end to end on a fresh macOS account — venv built, package installed from git, entry point linked — and `doctor` then died with an unhandled traceback: PermissionError: [Errno 13] Permission denied: 'ip' netpool's probe helpers caught only FileNotFoundError. That is not the only way a probe binary can be unavailable: when a name on PATH exists but this user cannot execute it, exec fails with EACCES, and CPython reports that in preference to the ENOENT from the other PATH entries. So `except FileNotFoundError` misses it and the crash propagates all the way out of `doctor`. Reproduced directly: a mode-000 file named `ip` on PATH yields exactly the error above. _run_ok's docstring already stated the intent — treat an unavailable binary as failure rather than crashing — so this widens the catch to OSError to match what it says. Any OSError means the probe could not run, which for a fail-closed check is indistinguishable from "not present". The same narrow catch is fixed in overlapping_routes and in the two docker probes (compose ls, docker ps), which are the same shape and equally reachable. Deliberately not touched: the FileNotFoundError catches around file I/O in bottle_state and orchestrator/service, where the narrow exception is correct. The harness also required `bot-bottle` on PATH before running doctor, which could never be true: install.sh prints the PATH line rather than editing a shell profile, by design, so on a fresh account the entry point is installed and working but not on PATH. It now looks where the installer actually puts it (~/.local/bin, then the venv) and notes when it's running by absolute path. That was the harness failing a run for a reason the installer intends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS55d79df9ab0to7461802b62"Does the installer work" and "can a new user actually run a bottle" are different questions, and collapsing them is what made the previous run ambiguous. Split them into two cycles over the same throwaway account: test a macOS system WITHOUT the prerequisites set up for this user, which is the default state of every new account since the Apple `container` service is per-user. Asserts the install is sound; reports backend readiness without failing on it, because install.sh provides no backend and cannot regress one. test-ready the same system WITH them. Runs `container system start` for the throwaway user, then demands doctor go fully green, backend included — the end-to-end claim. The new `prereqs` step runs `container system start` rather than `bot-bottle backend setup --backend=macos-container`, because that subcommand does not start anything: it checks, then tells you to run `container system start` yourself (backend/macos_container/setup.py, "no privileged host setup required"). The harness runs what the product actually asks for. test-ready sets BB_TEST_REQUIRE_BACKEND, so it demands exactly what `test` merely reports. Both share one cycle function; the step count and the PASS claim are the only differences beyond the extra step, so neither variant can drift from the other's teardown or dirty-account guarantees. Rig covers the distinguishing case directly: identical host state where `test` passes on install soundness and `test-ready` starts the service and reaches a green backend, plus the service-start failure and an install failure under test-ready. 22 scenarios, all passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5`test` passes. `test-ready` got as far as the service start and failed: Launching container-apiserver... Error: failed to get a response from apiserver: invalidState: "unauthorized request" which is the limitation this script's own header predicted — "may need a full launchd user session (`launchctl asuser`)". `container system start` registers com.apple.container.apiserver as a per-USER launchd agent and then talks to it over XPC. `launchctl list` confirms the shape: the apiserver and every container-network/runtime job are agents in the invoking user's domain. Under plain `sudo -u` the caller stays in root's bootstrap namespace, so the lookup crosses domains and the apiserver rejects it as unauthorized — the agent launched fine, the client just could not reach it. run_as_user now enters the target user's domain with `launchctl asuser <uid>`, which is also what a real user gets from Terminal, so it is the more faithful way to run everything here rather than a special case for the service start. A never-GUI-logged-in account may have no bootstrappable domain at all, so availability is probed once and cached, and everything falls back to plain `sudo -u` when it is missing. Without that fallback a missing domain would break `test` — which passes today and does not need a session — for a reason unrelated to what it tests. The rig covers that path explicitly. If the start still fails, the error now names the cause and the way out (log into the account once to get it a domain) instead of just relaying the CLI's message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5The launchd fix worked. `container system start` now gets past "Testing access to container-apiserver" and "Verifying machine API server is running" — the two steps it could not reach before — so the XPC domain problem is solved. It then failed somewhere new: No default kernel configured. Error: failed to read user input Install the recommended default kernel from [...kata-static...]? [Y/n]: `container system start` PROMPTS for the default Linux kernel when given neither --enable-kernel-install nor --disable-kernel-install ("default: prompt user"). A throwaway account always hits that prompt, because the kernel lives in the per-user app root and a fresh home has none — and with no TTY the prompt dies immediately. Pass --enable-kernel-install. That is also the honest choice for what this variant claims: the backend cannot run a bottle without a guest kernel, so "prerequisites satisfied" has to include it. The cost is a kernel download per run, since the previous run's copy went with the deleted home; BB_TEST_KERNEL_INSTALL=0 switches to --disable-kernel-install when you only care that the service comes up. This also means the per-user prerequisite is not one step but two — start the service, install a kernel — which is worth knowing for anyone setting up a second account by hand. The rig's stub now models the prompt: an unflagged start fails the way the real CLI does. Verified that guard bites by removing the flag and watching the scenario fail, so a future edit cannot silently reintroduce an interactive prereq step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5