diff --git a/README.md b/README.md index 67d52c3..9cd3a46 100644 --- a/README.md +++ b/README.md @@ -358,10 +358,17 @@ bash scripts/lume/create-base-vm.sh --config config/lume-runners.yaml --env .env bash scripts/lume/setup-base-vm.sh --config config/lume-runners.yaml --env .env bash scripts/lume/reconcile-pool.sh --config config/lume-runners.yaml --env .env bash scripts/lume/status.sh --config config/lume-runners.yaml --env .env +bash scripts/lume/install-runtime.sh +bash scripts/lume/install-launch-agent.sh +sudo bash scripts/lume/install-system-launch-daemons.sh --disable-user-lume-agent ``` Keep the Lume runner env file outside git and locked down with `chmod 600`. The host controller reads that file and copies it into each guest VM just before starting the guest bootstrap. Do not bake GitHub credentials into the base VM image. If you want the macOS/base-image pipeline to stay pinned to a specific GitHub Actions runner build, set `pool.runnerVersion` in `config/lume-runners.yaml`; otherwise it falls back to `RUNNER_VERSION` from the env file. +The launchd installers publish a source-independent controller runtime under `~/Library/Application Support/github-runner-fleet/controller/current` and point the Lume pool job at that path. Runtime `.env` remains beside that controller at `~/Library/Application Support/github-runner-fleet/controller/.env`, is mode `0600`, and is preserved when the source checkout moves or the installer is rerun. Override the runtime root with `GITHUB_RUNNER_FLEET_RUNTIME_ROOT` if this Mac needs a different stable location. + +If launchd reports `Bootstrap failed: 5: Input/output error`, check the disabled override first with `launchctl print-disabled system | rg github-runner-fleet` or `launchctl print-disabled gui/$(id -u) | rg github-runner-fleet`. The installers clear their own disabled overrides before bootstrapping; the system installer only disables the per-user Lume jobs after the root services load successfully. + `create-base-vm.sh` now caches the macOS IPSW under `LUME_RUNNER_BASE_DIR/cache/` by default so rebuilding the base image does not re-download the restore image every time. Override that path with `LUME_RUNNER_IPSW_PATH` if you want the cache elsewhere. If unattended setup drifts or gets interrupted, rerun `scripts/lume/setup-base-vm.sh` against the existing base VM instead of deleting and recreating it. ## Security Notes diff --git a/scripts/lume/install-launch-agent.sh b/scripts/lume/install-launch-agent.sh index 34182cf..90f457f 100755 --- a/scripts/lume/install-launch-agent.sh +++ b/scripts/lume/install-launch-agent.sh @@ -3,6 +3,7 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +source "${SCRIPT_DIR}/install-runtime.sh" LAUNCH_AGENT_LABEL="com.omt.github-runner-fleet.lume-pool" LAUNCH_AGENTS_DIR="${HOME}/Library/LaunchAgents" @@ -12,6 +13,8 @@ STDOUT_PATH="${LOG_DIR}/lume-pool.stdout.log" STDERR_PATH="${LOG_DIR}/lume-pool.stderr.log" DOMAIN_TARGET="gui/$(id -u)" +export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${HOME}/.local/bin:${PATH:-}" + usage() { cat <${rtk_path} bash -lc - cd '${REPO_ROOT}' && exec bash scripts/lume/reconcile-pool.sh --config config/lume-runners.yaml --env .env + cd '${runtime_repo}' && exec bash scripts/lume/reconcile-pool.sh --config config/lume-runners.yaml --env '${runtime_env}' WorkingDirectory - ${REPO_ROOT} + ${runtime_repo} RunAtLoad KeepAlive @@ -75,11 +80,14 @@ write_plist() { EOF plutil -lint "${temp_path}" >/dev/null - mv "${temp_path}" "${PLIST_PATH}" + install -m 0644 "${temp_path}" "${PLIST_PATH}" + rm -f "${temp_path}" } main() { local rtk_path + local runtime_repo + local runtime_env if [[ $# -gt 0 ]]; then case "$1" in @@ -100,11 +108,15 @@ main() { require_command rtk rtk_path="$(command -v rtk)" + install_lume_controller_runtime "${HOME}" + runtime_repo="$(lume_controller_runtime_repo "${HOME}")" + runtime_env="$(lume_controller_runtime_env "${HOME}")" mkdir -p "${LAUNCH_AGENTS_DIR}" "${LOG_DIR}" - write_plist "${rtk_path}" + write_plist "${rtk_path}" "${runtime_repo}" "${runtime_env}" launchctl bootout "${DOMAIN_TARGET}" "${PLIST_PATH}" >/dev/null 2>&1 || true + launchctl enable "${DOMAIN_TARGET}/${LAUNCH_AGENT_LABEL}" launchctl bootstrap "${DOMAIN_TARGET}" "${PLIST_PATH}" launchctl enable "${DOMAIN_TARGET}/${LAUNCH_AGENT_LABEL}" launchctl kickstart -k "${DOMAIN_TARGET}/${LAUNCH_AGENT_LABEL}" diff --git a/scripts/lume/install-runtime.sh b/scripts/lume/install-runtime.sh new file mode 100755 index 0000000..2440e1e --- /dev/null +++ b/scripts/lume/install-runtime.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +usage() { + cat </dev/null 2>&1; then + echo "missing required command: ${command_name}" >&2 + exit 1 + fi +} + +lume_controller_runtime_root() { + local target_home="$1" + + if [[ -n "${GITHUB_RUNNER_FLEET_RUNTIME_ROOT:-}" ]]; then + printf '%s\n' "${GITHUB_RUNNER_FLEET_RUNTIME_ROOT}" + return 0 + fi + + printf '%s/Library/Application Support/github-runner-fleet/controller\n' "${target_home}" +} + +lume_controller_runtime_repo() { + local target_home="$1" + printf '%s/current\n' "$(lume_controller_runtime_root "${target_home}")" +} + +lume_controller_runtime_env() { + local target_home="$1" + printf '%s/.env\n' "$(lume_controller_runtime_root "${target_home}")" +} + +write_default_runtime_env() { + local env_path="$1" + local target_home="$2" + local lume_base_dir="${target_home}/Library/Application Support/github-runner-fleet/lume" + local temp_path + + temp_path="$(mktemp)" + cat > "${temp_path}" <&2 + echo "unknown argument: $1" >&2 + return 1 + ;; + esac + done + + install_lume_controller_runtime "${target_home}" "${target_user}" "${target_group}" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/lume/install-system-launch-daemons.sh b/scripts/lume/install-system-launch-daemons.sh index a02e767..a2dfc3b 100755 --- a/scripts/lume/install-system-launch-daemons.sh +++ b/scripts/lume/install-system-launch-daemons.sh @@ -3,10 +3,12 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +source "${SCRIPT_DIR}/install-runtime.sh" TARGET_USER="${SUDO_USER:-$(stat -f '%Su' /dev/console)}" TARGET_HOME="$(dscl . -read "/Users/${TARGET_USER}" NFSHomeDirectory | awk '{print $2}')" TARGET_GROUP="$(id -gn "${TARGET_USER}")" +export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${TARGET_HOME}/.local/bin:${PATH:-}" LUME_LABEL="com.omt.github-runner-fleet.lume-serve" POOL_LABEL="com.omt.github-runner-fleet.lume-pool.system" DAEMON_DIR="/Library/LaunchDaemons" @@ -14,7 +16,10 @@ LOG_DIR="${TARGET_HOME}/Library/Logs/github-runner-fleet" LUME_PLIST_PATH="${DAEMON_DIR}/${LUME_LABEL}.plist" POOL_PLIST_PATH="${DAEMON_DIR}/${POOL_LABEL}.plist" USER_LUME_AGENT_PATH="${TARGET_HOME}/Library/LaunchAgents/com.trycua.lume_daemon.plist" +USER_POOL_AGENT_LABEL="com.omt.github-runner-fleet.lume-pool" +USER_POOL_AGENT_PATH="${TARGET_HOME}/Library/LaunchAgents/${USER_POOL_AGENT_LABEL}.plist" DISABLE_USER_LUME_AGENT="false" +DISABLE_USER_POOL_AGENT="true" usage() { cat <&2 echo "unknown argument: $1" >&2 @@ -119,6 +129,8 @@ EOF write_pool_plist() { local rtk_path="$1" + local runtime_repo="$2" + local runtime_env="$3" local temp_path temp_path="$(mktemp)" @@ -138,10 +150,10 @@ write_pool_plist() { ${rtk_path} bash -lc - cd '${REPO_ROOT}' && exec bash scripts/lume/reconcile-pool.sh --config config/lume-runners.yaml --env .env + cd '${runtime_repo}' && exec bash scripts/lume/reconcile-pool.sh --config config/lume-runners.yaml --env '${runtime_env}' WorkingDirectory - ${REPO_ROOT} + ${runtime_repo} RunAtLoad KeepAlive @@ -177,11 +189,26 @@ disable_user_lume_agent() { launchctl disable "gui/${uid}/com.trycua.lume_daemon" >/dev/null 2>&1 || true } +disable_user_pool_agent() { + local uid + + uid="$(id -u "${TARGET_USER}")" + if [[ "${DISABLE_USER_POOL_AGENT}" != "true" ]]; then + return 0 + fi + + if [[ -f "${USER_POOL_AGENT_PATH}" ]]; then + launchctl bootout "gui/${uid}" "${USER_POOL_AGENT_PATH}" >/dev/null 2>&1 || true + fi + launchctl disable "gui/${uid}/${USER_POOL_AGENT_LABEL}" >/dev/null 2>&1 || true +} + bootstrap_daemon() { local label="$1" local plist_path="$2" launchctl bootout system "${plist_path}" >/dev/null 2>&1 || true + launchctl enable "system/${label}" launchctl bootstrap system "${plist_path}" launchctl enable "system/${label}" launchctl kickstart -k "system/${label}" @@ -190,6 +217,8 @@ bootstrap_daemon() { main() { local lume_path local rtk_path + local runtime_repo + local runtime_env require_command dscl require_command id @@ -209,12 +238,16 @@ main() { mkdir -p "${DAEMON_DIR}" "${LOG_DIR}" chown "${TARGET_USER}:${TARGET_GROUP}" "${LOG_DIR}" + install_lume_controller_runtime "${TARGET_HOME}" "${TARGET_USER}" "${TARGET_GROUP}" + runtime_repo="$(lume_controller_runtime_repo "${TARGET_HOME}")" + runtime_env="$(lume_controller_runtime_env "${TARGET_HOME}")" write_lume_plist "${lume_path}" - write_pool_plist "${rtk_path}" - disable_user_lume_agent + write_pool_plist "${rtk_path}" "${runtime_repo}" "${runtime_env}" bootstrap_daemon "${LUME_LABEL}" "${LUME_PLIST_PATH}" bootstrap_daemon "${POOL_LABEL}" "${POOL_PLIST_PATH}" + disable_user_lume_agent + disable_user_pool_agent printf 'installed %s at %s\n' "${LUME_LABEL}" "${LUME_PLIST_PATH}" printf 'installed %s at %s\n' "${POOL_LABEL}" "${POOL_PLIST_PATH}" diff --git a/scripts/lume/reconcile-pool.sh b/scripts/lume/reconcile-pool.sh index 21f9cf4..57fd047 100755 --- a/scripts/lume/reconcile-pool.sh +++ b/scripts/lume/reconcile-pool.sh @@ -42,6 +42,29 @@ while [[ $# -gt 0 ]]; do esac done +wait_for_registration_env() { + local env_file="$1" + + while true; do + if ( + set +u + if [[ -f "${env_file}" ]]; then + set -a + # shellcheck disable=SC1090 + source "${env_file}" + set +a + fi + + [[ -n "${GITHUB_PAT:-}" ]] + ); then + return 0 + fi + + log "missing GITHUB_PAT in ${env_file}; waiting before starting Lume slots" + sleep 60 + done +} + retire_removed_slots_from_state() { local state_file="$1" local current_pool_size="$2" @@ -181,6 +204,7 @@ fs.renameSync(tempFile, process.env.STATE_FILE); NODE } +wait_for_registration_env "${env_path}" pool_size="$(load_pool_size "${config_path}" "${env_path}")" load_slot_env "1" "${config_path}" "${env_path}" reconcile_state_file="${LUME_RECONCILE_STATE_FILE}" diff --git a/test/lume-scripts.test.ts b/test/lume-scripts.test.ts index d759700..00aad97 100644 --- a/test/lume-scripts.test.ts +++ b/test/lume-scripts.test.ts @@ -11,6 +11,7 @@ describe("Lume pool scripts", () => { const createBase = read("scripts/lume/create-base-vm.sh"); const setupBase = read("scripts/lume/setup-base-vm.sh"); const provisionBase = read("scripts/lume/provision-base-vm.sh"); + const installRuntime = read("scripts/lume/install-runtime.sh"); const installLaunchAgent = read("scripts/lume/install-launch-agent.sh"); const installLaunchDaemons = read("scripts/lume/install-system-launch-daemons.sh"); @@ -24,6 +25,8 @@ describe("Lume pool scripts", () => { expect(runSlot).toContain('guest_env_file="$(render_guest_runner_env "${env_path}")"'); expect(runSlot).toContain('lume ssh "${LUME_VM_NAME}"'); expect(reconcile).toContain("retire_removed_slots_from_state"); + expect(reconcile).toContain("wait_for_registration_env"); + expect(reconcile).toContain("missing GITHUB_PAT"); expect(reconcile).toContain("write_reconcile_state"); expect(reconcile).toContain('reconcile_state_file="${LUME_RECONCILE_STATE_FILE}"'); expect(reconcile).toContain('spawn_detached'); @@ -37,14 +40,37 @@ describe("Lume pool scripts", () => { expect(provisionBase).toContain("tar -C"); expect(provisionBase).toContain("sudo -S -p '' tar -xf"); expect(provisionBase).toContain("sudo -S -p '' xcodebuild -runFirstLaunch"); + expect(installRuntime).toContain("GITHUB_RUNNER_FLEET_RUNTIME_ROOT"); + expect(installRuntime).toContain("Library/Application Support/github-runner-fleet/controller"); + expect(installRuntime).toContain("rsync -a --delete"); + expect(installRuntime).toContain("pnpm --dir"); + expect(installRuntime).toContain("install_lume_controller_runtime"); + expect(installRuntime).toContain('if [[ -f "${runtime_env}" ]]'); + expect(installRuntime.indexOf('if [[ -f "${runtime_env}" ]]')).toBeLessThan( + installRuntime.indexOf('install -m 0600 "${REPO_ROOT}/.env" "${runtime_env}"'), + ); expect(installLaunchAgent).toContain('com.omt.github-runner-fleet.lume-pool'); - expect(installLaunchAgent).toContain('scripts/lume/reconcile-pool.sh --config config/lume-runners.yaml --env .env'); + expect(installLaunchAgent).toContain('source "${SCRIPT_DIR}/install-runtime.sh"'); + expect(installLaunchAgent).toContain('install_lume_controller_runtime "${HOME}"'); + expect(installLaunchAgent).toContain('scripts/lume/reconcile-pool.sh --config config/lume-runners.yaml --env \'${runtime_env}\''); expect(installLaunchAgent).toContain('launchctl bootstrap "${DOMAIN_TARGET}" "${PLIST_PATH}"'); + expect(installLaunchAgent.indexOf('launchctl enable "${DOMAIN_TARGET}/${LAUNCH_AGENT_LABEL}"')).toBeLessThan( + installLaunchAgent.indexOf('launchctl bootstrap "${DOMAIN_TARGET}" "${PLIST_PATH}"'), + ); expect(installLaunchDaemons).toContain('run as root: sudo $0'); expect(installLaunchDaemons).toContain('/Library/LaunchDaemons'); expect(installLaunchDaemons).toContain('com.omt.github-runner-fleet.lume-serve'); expect(installLaunchDaemons).toContain('com.omt.github-runner-fleet.lume-pool.system'); + expect(installLaunchDaemons).toContain('disable_user_pool_agent'); + expect(installLaunchDaemons).toContain('install_lume_controller_runtime "${TARGET_HOME}" "${TARGET_USER}" "${TARGET_GROUP}"'); expect(installLaunchDaemons).toContain('launchctl bootstrap system "${plist_path}"'); + expect(installLaunchDaemons.indexOf('launchctl enable "system/${label}"')).toBeLessThan( + installLaunchDaemons.indexOf('launchctl bootstrap system "${plist_path}"'), + ); + const installLaunchDaemonsMain = installLaunchDaemons.slice(installLaunchDaemons.indexOf("main() {")); + expect(installLaunchDaemonsMain.indexOf('bootstrap_daemon "${POOL_LABEL}" "${POOL_PLIST_PATH}"')).toBeLessThan( + installLaunchDaemonsMain.indexOf("disable_user_pool_agent"), + ); }); test("documents operator-facing lume script usage", () => { @@ -52,6 +78,7 @@ describe("Lume pool scripts", () => { "scripts/lume/create-base-vm.sh", "scripts/lume/create-slot.sh", "scripts/lume/destroy-slot.sh", + "scripts/lume/install-runtime.sh", "scripts/lume/install-launch-agent.sh", "scripts/lume/install-system-launch-daemons.sh", "scripts/lume/provision-base-vm.sh",