From 06436c80e05bb0158297933c5945cb93510ecb86 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 25 Apr 2026 10:36:08 -0500 Subject: [PATCH] Harden Lume runner lifecycle scripts --- scripts/guest/macos-runner-bootstrap.sh | 8 ++++ scripts/lume/create-base-vm.sh | 20 ++++++++ scripts/lume/create-slot.sh | 28 ++++++++++- scripts/lume/destroy-slot.sh | 20 ++++++++ scripts/lume/install-launch-agent.sh | 25 ++++++++++ scripts/lume/install-system-launch-daemons.sh | 28 +++++++++-- scripts/lume/lib.sh | 46 ++++++++++++++++++- scripts/lume/provision-base-vm.sh | 20 ++++++++ scripts/lume/reconcile-pool.sh | 27 +++++++++-- scripts/lume/run-slot.sh | 20 ++++++++ scripts/lume/setup-base-vm.sh | 21 +++++++++ scripts/lume/status.sh | 18 ++++++++ test/lume-scripts.test.ts | 31 ++++++++++++- 13 files changed, 299 insertions(+), 13 deletions(-) diff --git a/scripts/guest/macos-runner-bootstrap.sh b/scripts/guest/macos-runner-bootstrap.sh index c4e470f..0ad5503 100755 --- a/scripts/guest/macos-runner-bootstrap.sh +++ b/scripts/guest/macos-runner-bootstrap.sh @@ -48,6 +48,12 @@ prepare_runner_home() { } cleanup_runner() { + if [[ ! -f "${RUNNER_ROOT}/.runner" ]]; then + log "runner registration already removed by ephemeral listener" + cleanup_local_state + return 0 + fi + cleanup_runner_registration "cd '${RUNNER_ROOT}' && ./config.sh remove --token \"\${RUNNER_REMOVE_TOKEN}\"" } @@ -69,6 +75,8 @@ require_env RUNNER_ROOT require_env RUNNER_WORK_DIR require_env RUNNER_VERSION +export PATH="${RUNNER_PATH:-/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${HOME}/.local/bin}" + prepare_runner_home cleanup_local_state diff --git a/scripts/lume/create-base-vm.sh b/scripts/lume/create-base-vm.sh index 185b2cf..b4d8450 100755 --- a/scripts/lume/create-base-vm.sh +++ b/scripts/lume/create-base-vm.sh @@ -4,6 +4,21 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/lib.sh" +usage() { + cat <&2 echo "unknown argument: $1" >&2 exit 1 ;; diff --git a/scripts/lume/create-slot.sh b/scripts/lume/create-slot.sh index 002a692..9694cda 100755 --- a/scripts/lume/create-slot.sh +++ b/scripts/lume/create-slot.sh @@ -4,12 +4,30 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/lib.sh" +usage() { + cat <&2 echo "unknown argument: $1" >&2 exit 1 ;; @@ -30,6 +49,7 @@ while [[ $# -gt 0 ]]; do done if [[ -z "${slot}" ]]; then + usage >&2 echo "--slot is required" >&2 exit 1 fi @@ -47,8 +67,12 @@ lume clone "${LUME_VM_BASE_NAME}" "${LUME_VM_NAME}" $(clone_args) >/dev/null lume set "${LUME_VM_NAME}" --cpu "${LUME_VM_CPU}" --memory "${LUME_VM_MEMORY}" --disk-size "${LUME_VM_DISK_SIZE}" $(storage_args) >/dev/null log "starting ${LUME_VM_NAME}" -nohup lume run "${LUME_VM_NAME}" --no-display --network "${LUME_VM_NETWORK}" $(storage_args) >"${LUME_SLOT_VM_LOG_FILE}" 2>&1 & -echo $! > "${LUME_SLOT_VM_PID_FILE}" +vm_pid="$( + spawn_detached \ + "${LUME_SLOT_VM_LOG_FILE}" \ + lume run "${LUME_VM_NAME}" --no-display --network "${LUME_VM_NETWORK}" $(storage_args) +)" +echo "${vm_pid}" > "${LUME_SLOT_VM_PID_FILE}" wait_for_ssh log "slot ${LUME_VM_NAME} is reachable over SSH" diff --git a/scripts/lume/destroy-slot.sh b/scripts/lume/destroy-slot.sh index 6355410..bf83cab 100755 --- a/scripts/lume/destroy-slot.sh +++ b/scripts/lume/destroy-slot.sh @@ -4,12 +4,30 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/lib.sh" +usage() { + cat <&2 echo "unknown argument: $1" >&2 exit 1 ;; @@ -30,6 +49,7 @@ while [[ $# -gt 0 ]]; do done if [[ -z "${slot}" ]]; then + usage >&2 echo "--slot is required" >&2 exit 1 fi diff --git a/scripts/lume/install-launch-agent.sh b/scripts/lume/install-launch-agent.sh index c5b01ba..34182cf 100755 --- a/scripts/lume/install-launch-agent.sh +++ b/scripts/lume/install-launch-agent.sh @@ -12,6 +12,17 @@ STDOUT_PATH="${LOG_DIR}/lume-pool.stdout.log" STDERR_PATH="${LOG_DIR}/lume-pool.stderr.log" DOMAIN_TARGET="gui/$(id -u)" +usage() { + cat <&2 + echo "unknown argument: $1" >&2 + return 1 + ;; + esac + fi + require_command launchctl require_command plutil require_command rtk diff --git a/scripts/lume/install-system-launch-daemons.sh b/scripts/lume/install-system-launch-daemons.sh index c79c551..a02e767 100755 --- a/scripts/lume/install-system-launch-daemons.sh +++ b/scripts/lume/install-system-launch-daemons.sh @@ -1,11 +1,6 @@ #!/usr/bin/env bash set -Eeuo pipefail -if [[ "${EUID}" -ne 0 ]]; then - echo "run as root: sudo $0" >&2 - exit 1 -fi - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" @@ -21,19 +16,42 @@ POOL_PLIST_PATH="${DAEMON_DIR}/${POOL_LABEL}.plist" USER_LUME_AGENT_PATH="${TARGET_HOME}/Library/LaunchAgents/com.trycua.lume_daemon.plist" DISABLE_USER_LUME_AGENT="false" +usage() { + cat <&2 echo "unknown argument: $1" >&2 exit 1 ;; esac done +if [[ "${EUID}" -ne 0 ]]; then + usage >&2 + echo "run as root: sudo $0" >&2 + exit 1 +fi + require_command() { local command_name="$1" diff --git a/scripts/lume/lib.sh b/scripts/lume/lib.sh index 8a73a62..9f46469 100755 --- a/scripts/lume/lib.sh +++ b/scripts/lume/lib.sh @@ -17,6 +17,10 @@ default_lume_unattended_path() { printf '%s/scripts/lume/unattended-sequoia.yml' "${REPO_ROOT}" } +default_guest_runner_path() { + printf '%s\n' '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${HOME}/.local/bin' +} + load_slot_env() { local slot="$1" local config_path="$2" @@ -94,11 +98,16 @@ clone_args() { wait_for_ssh() { local attempt + local ssh_output + local ssh_exit for attempt in $(seq 1 60); do - if lume ssh "${LUME_VM_NAME}" --user "${GUEST_USER}" --password "${GUEST_PASSWORD}" --timeout 10 "true" >/dev/null 2>&1; then + # Use 'if' to suppress set -e so a failed SSH attempt does not abort the loop. + if ssh_output="$(lume ssh "${LUME_VM_NAME}" --user "${GUEST_USER}" --password "${GUEST_PASSWORD}" --timeout 10 "true" 2>&1)"; then return 0 fi + ssh_exit=$? + log "wait_for_ssh attempt ${attempt}/60: exit=${ssh_exit} output=[${ssh_output}]" sleep 5 done @@ -129,8 +138,10 @@ upload_env_file() { render_guest_runner_env() { local env_path="$1" local temp_env + local runner_path temp_env="$(mktemp)" + runner_path="${RUNNER_PATH:-$(default_guest_runner_path)}" ( set -a # shellcheck disable=SC1090 @@ -147,6 +158,7 @@ RUNNER_LABELS=${RUNNER_LABELS} RUNNER_NAME=${RUNNER_NAME} RUNNER_ROOT=${RUNNER_ROOT} RUNNER_WORK_DIR=${RUNNER_WORK_DIR} +RUNNER_PATH=${runner_path} RUNNER_VERSION=${RUNNER_VERSION} RUNNER_DOWNLOAD_URL=${RUNNER_DOWNLOAD_URL:-} EOF @@ -158,3 +170,35 @@ EOF vm_exists() { lume get "${LUME_VM_NAME}" --format json $(storage_args) >/dev/null 2>&1 } + +spawn_detached() { + local log_path="$1" + shift + + python3 - "${log_path}" "$@" <<'PY' +import os +import sys + +log_path = sys.argv[1] +command = sys.argv[2:] + +pid = os.fork() +if pid: + print(pid) + sys.exit(0) + +os.setsid() + +stdin_fd = os.open("/dev/null", os.O_RDONLY) +log_fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644) + +os.dup2(stdin_fd, 0) +os.dup2(log_fd, 1) +os.dup2(log_fd, 2) + +os.close(stdin_fd) +os.close(log_fd) + +os.execvp(command[0], command) +PY +} diff --git a/scripts/lume/provision-base-vm.sh b/scripts/lume/provision-base-vm.sh index fe1c5b8..07e2ae1 100755 --- a/scripts/lume/provision-base-vm.sh +++ b/scripts/lume/provision-base-vm.sh @@ -4,6 +4,21 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/lib.sh" +usage() { + cat <&2 echo "unknown argument: $1" >&2 exit 1 ;; diff --git a/scripts/lume/reconcile-pool.sh b/scripts/lume/reconcile-pool.sh index 7c74f49..21f9cf4 100755 --- a/scripts/lume/reconcile-pool.sh +++ b/scripts/lume/reconcile-pool.sh @@ -4,11 +4,28 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/lib.sh" +usage() { + cat <&2 echo "unknown argument: $1" >&2 exit 1 ;; @@ -183,9 +201,12 @@ while true; do fi log "starting slot worker ${slot} (${LUME_VM_NAME})" - nohup "${SCRIPT_DIR}/run-slot.sh" --slot "${slot}" --config "${config_path}" --env "${env_path}" \ - >> "${LUME_SLOT_LOG_FILE}" 2>&1 & - echo $! > "${LUME_SLOT_WORKER_PID_FILE}" + worker_pid="$( + spawn_detached \ + "${LUME_SLOT_LOG_FILE}" \ + "${SCRIPT_DIR}/run-slot.sh" --slot "${slot}" --config "${config_path}" --env "${env_path}" + )" + echo "${worker_pid}" > "${LUME_SLOT_WORKER_PID_FILE}" write_slot_state_record "${state_records_file}" done diff --git a/scripts/lume/run-slot.sh b/scripts/lume/run-slot.sh index 9182892..ec4f3b0 100755 --- a/scripts/lume/run-slot.sh +++ b/scripts/lume/run-slot.sh @@ -4,12 +4,30 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/lib.sh" +usage() { + cat <&2 echo "unknown argument: $1" >&2 exit 1 ;; @@ -30,6 +49,7 @@ while [[ $# -gt 0 ]]; do done if [[ -z "${slot}" ]]; then + usage >&2 echo "--slot is required" >&2 exit 1 fi diff --git a/scripts/lume/setup-base-vm.sh b/scripts/lume/setup-base-vm.sh index 5ecbd1f..5506243 100644 --- a/scripts/lume/setup-base-vm.sh +++ b/scripts/lume/setup-base-vm.sh @@ -4,6 +4,22 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/lib.sh" +usage() { + cat <&2 echo "unknown argument: $1" >&2 exit 1 ;; diff --git a/scripts/lume/status.sh b/scripts/lume/status.sh index 2f57937..a95cff9 100755 --- a/scripts/lume/status.sh +++ b/scripts/lume/status.sh @@ -4,11 +4,28 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/lib.sh" +usage() { + cat <&2 echo "unknown argument: $1" >&2 exit 1 ;; diff --git a/test/lume-scripts.test.ts b/test/lume-scripts.test.ts index 20251e3..d759700 100644 --- a/test/lume-scripts.test.ts +++ b/test/lume-scripts.test.ts @@ -16,7 +16,8 @@ describe("Lume pool scripts", () => { expect(createSlot).toContain('lume clone "${LUME_VM_BASE_NAME}" "${LUME_VM_NAME}"'); expect(createSlot).toContain('lume set "${LUME_VM_NAME}" --cpu "${LUME_VM_CPU}"'); - expect(createSlot).toContain('nohup lume run "${LUME_VM_NAME}" --no-display'); + expect(createSlot).toContain('spawn_detached'); + expect(createSlot).toContain('lume run "${LUME_VM_NAME}" --no-display'); expect(destroySlot).toContain('lume stop "${LUME_VM_NAME}"'); expect(destroySlot).toContain('lume delete "${LUME_VM_NAME}" --force'); expect(runSlot).toContain("uploading guest bootstrap assets"); @@ -25,7 +26,10 @@ describe("Lume pool scripts", () => { expect(reconcile).toContain("retire_removed_slots_from_state"); expect(reconcile).toContain("write_reconcile_state"); expect(reconcile).toContain('reconcile_state_file="${LUME_RECONCILE_STATE_FILE}"'); - expect(reconcile).toContain('nohup "${SCRIPT_DIR}/run-slot.sh" --slot "${slot}"'); + expect(reconcile).toContain('spawn_detached'); + expect(reconcile).toContain('"${SCRIPT_DIR}/run-slot.sh" --slot "${slot}"'); + expect(read("scripts/lume/lib.sh")).toContain("default_guest_runner_path"); + expect(read("scripts/lume/lib.sh")).toContain("RUNNER_PATH=${runner_path}"); expect(createBase).toContain('unattended="$(default_lume_unattended_path)"'); expect(createBase).toContain('ipsw="$(ensure_cached_lume_ipsw "$(resolve_lume_ipsw_path)")"'); expect(setupBase).toContain('lume stop "${LUME_VM_BASE_NAME}"'); @@ -43,6 +47,27 @@ describe("Lume pool scripts", () => { expect(installLaunchDaemons).toContain('launchctl bootstrap system "${plist_path}"'); }); + test("documents operator-facing lume script usage", () => { + const scriptPaths = [ + "scripts/lume/create-base-vm.sh", + "scripts/lume/create-slot.sh", + "scripts/lume/destroy-slot.sh", + "scripts/lume/install-launch-agent.sh", + "scripts/lume/install-system-launch-daemons.sh", + "scripts/lume/provision-base-vm.sh", + "scripts/lume/reconcile-pool.sh", + "scripts/lume/run-slot.sh", + "scripts/lume/setup-base-vm.sh", + "scripts/lume/status.sh", + ]; + + for (const relativePath of scriptPaths) { + const script = read(relativePath); + expect(script, `${relativePath} should define usage text`).toContain("Usage:"); + expect(script, `${relativePath} should accept --help`).toMatch(/-h\|--help/); + } + }); + test("bootstraps ephemeral macOS runners inside guest VMs", () => { const bootstrap = read("scripts/guest/macos-runner-bootstrap.sh"); const helper = read("scripts/lib/github-runner-common.sh"); @@ -50,6 +75,8 @@ describe("Lume pool scripts", () => { expect(bootstrap).toContain("actions-runner-osx-arm64-${RUNNER_VERSION}.tar.gz"); expect(bootstrap).toContain("--ephemeral"); expect(bootstrap).toContain("--disableupdate"); + expect(bootstrap).toContain('if [[ ! -f "${RUNNER_ROOT}/.runner" ]]'); + expect(bootstrap).toContain('export PATH="${RUNNER_PATH:-/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${HOME}/.local/bin}"'); expect(bootstrap).toContain('cleanup_runner_registration'); expect(helper).toContain("github_runner_endpoint_base"); expect(helper).toContain("request_runner_token");