diff --git a/.changeset/config.json b/.changeset/config.json index ceb6b0884f..9313193c89 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -19,7 +19,8 @@ "@expo/downloader", "@expo/turtle-spawn", "@expo/template-file", - "create-eas-build-function" + "create-eas-build-function", + "@expo/vm-tools" ] ], "linked": [], diff --git a/.changeset/vm-tools.md b/.changeset/vm-tools.md new file mode 100644 index 0000000000..01e0d6ddee --- /dev/null +++ b/.changeset/vm-tools.md @@ -0,0 +1,5 @@ +--- +'@expo/vm-tools': minor +--- + +Add a macOS startup script loader that runs `/usr/local/share/vm-tools/startup.sh` once per VM boot. diff --git a/packages/vm-tools/README.md b/packages/vm-tools/README.md new file mode 100644 index 0000000000..4516eb35f7 --- /dev/null +++ b/packages/vm-tools/README.md @@ -0,0 +1,25 @@ +# @expo/vm-tools + +A startup script loader for macOS VMs. + +## Install + +```sh +bunx @expo/vm-tools@latest install +``` + +Installs a per-user launchd agent and a standalone runner. The agent starts at the next GUI login; +installation does not load it or run a script. Automatic GUI login is required, and the Node +executable used for installation must remain available. + +## Startup script + +At login, vm-tools waits for `/usr/local/share/vm-tools/startup.sh` and runs it with +`/bin/bash` once per VM boot. Put the file in the VM or provide it through a shared directory. +The script does not need executable permission. + +The runner waits for the script to exit and returns its exit status. Failed scripts are not +restarted. The script owns its environment, processes and cleanup; use `exec` for a foreground +command so termination signals reach it. + +Output is written to `~/Library/Logs/vm-tools.log`. diff --git a/packages/vm-tools/bin/vm-tools b/packages/vm-tools/bin/vm-tools new file mode 100755 index 0000000000..23c4c41457 --- /dev/null +++ b/packages/vm-tools/bin/vm-tools @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('../dist/index').runCliAsync(); diff --git a/packages/vm-tools/jest.config.ts b/packages/vm-tools/jest.config.ts new file mode 100644 index 0000000000..9d280c0859 --- /dev/null +++ b/packages/vm-tools/jest.config.ts @@ -0,0 +1,12 @@ +import type { Config } from '@jest/types'; + +import baseConfig from '../../jest/jest.shared.config'; + +const config: Config.InitialOptions = { + ...baseConfig, + rootDir: 'src', + testMatch: ['**/__tests__/*.test.ts'], + clearMocks: true, +}; + +export default config; diff --git a/packages/vm-tools/package.json b/packages/vm-tools/package.json new file mode 100644 index 0000000000..cfef717a78 --- /dev/null +++ b/packages/vm-tools/package.json @@ -0,0 +1,37 @@ +{ + "name": "@expo/vm-tools", + "version": "27.1.0", + "description": "VM startup script loader", + "bugs": "https://github.com/expo/eas-cli/issues", + "license": "BUSL-1.1", + "author": "Expo ", + "repository": { + "type": "git", + "url": "https://github.com/expo/eas-cli.git", + "directory": "packages/vm-tools" + }, + "bin": "bin/vm-tools", + "files": [ + "bin", + "dist", + "resources" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "jest --config jest.config.ts", + "build": "ncc build src/main.ts -o dist --no-cache --no-source-map-register", + "typecheck": "tsc --noEmit", + "prepack": "yarn build", + "rebuild": "yarn build" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "22.20.5", + "@vercel/ncc": "^0.38.1", + "jest": "^29.7.0", + "ts-jest": "^29.4.12", + "typescript": "^6.0.3" + } +} diff --git a/packages/vm-tools/resources/com.expo.vm-tools.plist b/packages/vm-tools/resources/com.expo.vm-tools.plist new file mode 100644 index 0000000000..9184ec92a3 --- /dev/null +++ b/packages/vm-tools/resources/com.expo.vm-tools.plist @@ -0,0 +1,16 @@ + + + + + Labelcom.expo.vm-tools + LimitLoadToSessionTypeAqua + RunAtLoad + KeepAlive + ExitTimeOut150 + WorkingDirectory__HOME__ + StandardOutPath__HOME__/Library/Logs/vm-tools.log + StandardErrorPath__HOME__/Library/Logs/vm-tools.log + ProgramArguments + __NODE____HOME__/.expo/vm-tools/rundaemon + + diff --git a/packages/vm-tools/src/__tests__/main.test.ts b/packages/vm-tools/src/__tests__/main.test.ts new file mode 100644 index 0000000000..4c1e4cf8f0 --- /dev/null +++ b/packages/vm-tools/src/__tests__/main.test.ts @@ -0,0 +1,178 @@ +import { ChildProcess, execFile, spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +import { runStartupScriptOncePerBootAsync } from '../commands/daemon'; +import { installStartupDaemonAsync } from '../commands/install'; + +jest.mock('node:child_process', () => { + const actual = jest.requireActual('node:child_process'); + const { promisify } = jest.requireActual('node:util'); + return { + ...actual, + spawn: jest.fn(), + execFile: Object.assign(jest.fn(), { + [promisify.custom]: jest.fn(), + }), + }; +}); + +let root: string; +let home: string; +let directory: string; +let child: ChildProcess; + +beforeEach(async () => { + jest.resetAllMocks(); + root = await fs.mkdtemp(path.join(os.tmpdir(), 'vm-tools-test-')); + home = path.join(root, 'guest & home'); + directory = path.join(root, 'read only startup'); + await fs.mkdir(home); + await fs.mkdir(directory); + jest.spyOn(os, 'homedir').mockReturnValue(home); + jest.mocked(promisify(execFile)).mockResolvedValue({ stdout: 'boot-one\n', stderr: '' }); + child = Object.assign(new EventEmitter(), { kill: jest.fn() }) as unknown as ChildProcess; + jest.mocked(spawn).mockImplementation(() => { + setImmediate(() => child.emit('exit', 0, null)); + return child; + }); +}); + +afterEach(async () => { + jest.restoreAllMocks(); + await fs.rm(root, { recursive: true, force: true }); +}); + +test('install writes an offline runner and Aqua agent without running a payload', async () => { + const runnerPath = path.join(__dirname, '../main.ts'); + await installStartupDaemonAsync( + runnerPath, + path.join(__dirname, '../../resources/com.expo.vm-tools.plist') + ); + const plist = await fs.readFile( + path.join(home, 'Library/LaunchAgents/com.expo.vm-tools.plist'), + 'utf8' + ); + expect(plist).toContain('guest & home'); + expect(plist).toMatch(/KeepAlive<\/key>\s*/); + expect(plist).toMatch(/LimitLoadToSessionType<\/key>\s*Aqua<\/string>/); + expect(plist).toContain(process.execPath); + expect(plist).not.toContain('Umask'); + expect(plist).toContain('daemon'); + expect(plist).not.toContain('__NODE__'); + expect(await fs.readFile(path.join(home, '.expo/vm-tools/run'))).toEqual( + await fs.readFile(runnerPath) + ); + expect(spawn).not.toHaveBeenCalled(); + expect(promisify(execFile)).not.toHaveBeenCalled(); +}); + +test('waits for a nonexecutable script and executes it once with bash', async () => { + const pending = runStartupScriptOncePerBootAsync(directory); + await new Promise(resolve => setTimeout(resolve, 20)); + expect(spawn).not.toHaveBeenCalled(); + const script = path.join(directory, 'startup.sh'); + await fs.writeFile(script, 'exit 0\n'); + await expect(pending).resolves.toBe(0); + expect(spawn).toHaveBeenCalledWith('/bin/bash', [script], { stdio: 'inherit' }); + await expect(runStartupScriptOncePerBootAsync(directory)).resolves.toBe(0); + expect(spawn).toHaveBeenCalledTimes(1); +}); + +test('waits for child exit, returns its error code, and does not retry', async () => { + await fs.writeFile(path.join(directory, 'startup.sh'), 'exit 7\n'); + let launched!: () => void; + const started = new Promise(resolve => { + launched = resolve; + }); + jest.mocked(spawn).mockImplementation(() => { + launched(); + return child; + }); + let settled = false; + const pending = runStartupScriptOncePerBootAsync(directory).then(code => { + settled = true; + return code; + }); + await started; + expect(settled).toBe(false); + child.emit('exit', 7, null); + await expect(pending).resolves.toBe(7); + await expect(runStartupScriptOncePerBootAsync(directory)).resolves.toBe(0); + expect(spawn).toHaveBeenCalledTimes(1); +}); + +test('a launch error consumes this boot instead of restarting the script', async () => { + await fs.writeFile(path.join(directory, 'startup.sh'), 'exit 0\n'); + jest.mocked(spawn).mockImplementation(() => { + setImmediate(() => child.emit('error', new Error('spawn failed'))); + return child; + }); + await expect(runStartupScriptOncePerBootAsync(directory)).rejects.toThrow('spawn failed'); + await expect(runStartupScriptOncePerBootAsync(directory)).resolves.toBe(0); + expect(spawn).toHaveBeenCalledTimes(1); +}); + +test('concurrent dispatchers execute only one payload', async () => { + await fs.writeFile(path.join(directory, 'startup.sh'), 'exit 0\n'); + await expect( + Promise.all([ + runStartupScriptOncePerBootAsync(directory), + runStartupScriptOncePerBootAsync(directory), + ]) + ).resolves.toEqual([0, 0]); + expect(spawn).toHaveBeenCalledTimes(1); +}); + +test('forwards teardown signals to the foreground script and waits for its exit', async () => { + await fs.writeFile(path.join(directory, 'startup.sh'), 'exit 0\n'); + let launched!: () => void; + const started = new Promise(resolve => { + launched = resolve; + }); + jest.mocked(spawn).mockImplementation(() => { + launched(); + return child; + }); + const pending = runStartupScriptOncePerBootAsync(directory); + await started; + process.emit('SIGTERM', 'SIGTERM'); + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + child.emit('exit', null, 'SIGTERM'); + await expect(pending).resolves.toBe(1); + await expect(runStartupScriptOncePerBootAsync(directory)).resolves.toBe(0); + expect(spawn).toHaveBeenCalledTimes(1); +}); + +test('teardown before launch releases this boot for a later invocation', async () => { + await fs.writeFile(path.join(directory, 'startup.sh'), 'exit 0\n'); + const mkdir = fs.mkdir; + jest.spyOn(fs, 'mkdir').mockImplementation(async (target, options) => { + const result = await mkdir(target, options); + if (path.basename(String(target)) === 'started-boot-one') { + process.emit('SIGTERM', 'SIGTERM'); + } + return result; + }); + await expect(runStartupScriptOncePerBootAsync(directory)).rejects.toThrow( + 'VM startup was interrupted.' + ); + expect(spawn).not.toHaveBeenCalled(); + await expect(fs.stat(path.join(home, '.expo/vm-tools/started-boot-one'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + jest.mocked(fs.mkdir).mockRestore(); + await expect(runStartupScriptOncePerBootAsync(directory)).resolves.toBe(0); + expect(spawn).toHaveBeenCalledTimes(1); +}); + +test('teardown cancels waiting for a missing mount without executing anything', async () => { + const pending = runStartupScriptOncePerBootAsync(directory); + const rejected = expect(pending).rejects.toThrow(); + process.emit('SIGTERM', 'SIGTERM'); + await rejected; + expect(spawn).not.toHaveBeenCalled(); +}); diff --git a/packages/vm-tools/src/commands/daemon.ts b/packages/vm-tools/src/commands/daemon.ts new file mode 100644 index 0000000000..cec582b228 --- /dev/null +++ b/packages/vm-tools/src/commands/daemon.ts @@ -0,0 +1,72 @@ +import { execFile, spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { setTimeout as sleepAsync } from 'node:timers/promises'; +import { promisify } from 'node:util'; + +import { getVmStateDirectory } from '../utils/state'; + +const STARTUP_DIRECTORY = '/usr/local/share/vm-tools'; +const STARTUP_SCRIPT = 'startup.sh'; +const execFilePromise = promisify(execFile); + +export async function runStartupScriptOncePerBootAsync( + directory = STARTUP_DIRECTORY +): Promise { + const controller = new AbortController(); + let child: ReturnType | undefined; + const stop = (signal: NodeJS.Signals): void => { + controller.abort(new Error('VM startup was interrupted.')); + child?.kill(signal); + }; + process.on('SIGTERM', stop); + process.on('SIGINT', stop); + try { + const script = path.join(directory, STARTUP_SCRIPT); + while (true) { + controller.signal.throwIfAborted(); + try { + if (!(await fs.stat(script)).isFile()) { + throw new Error('VM startup.sh must be a regular file.'); + } + break; + } catch (error) { + if (!['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) { + throw error; + } + } + await sleepAsync(100, undefined, { signal: controller.signal }); + } + const { stdout } = await execFilePromise('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid']); + const boot = stdout.trim(); + if (!boot) { + throw new Error( + 'Could not read the VM boot identifier (kern.bootsessionuuid); startup cannot continue.' + ); + } + await fs.mkdir(getVmStateDirectory(), { recursive: true }); + const marker = path.join(getVmStateDirectory(), `started-${boot}`); + try { + await fs.mkdir(marker); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + return 0; + } + throw error; + } + if (controller.signal.aborted) { + // Nothing ran yet, so release this boot for a later invocation. + await fs.rmdir(marker); + controller.signal.throwIfAborted(); + } + const startupProcess = spawn('/bin/bash', [script], { stdio: 'inherit' }); + child = startupProcess; + return await new Promise((resolve, reject) => { + startupProcess.once('error', reject); + startupProcess.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))); + }); + } finally { + process.off('SIGTERM', stop); + process.off('SIGINT', stop); + } +} diff --git a/packages/vm-tools/src/commands/install.ts b/packages/vm-tools/src/commands/install.ts new file mode 100644 index 0000000000..8b55299896 --- /dev/null +++ b/packages/vm-tools/src/commands/install.ts @@ -0,0 +1,32 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { getVmStateDirectory } from '../utils/state'; + +const LABEL = 'com.expo.vm-tools'; + +function escapeXml(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +} + +export async function installStartupDaemonAsync( + runnerPath: string, + plistPath: string +): Promise { + const home = os.homedir(); + const directory = getVmStateDirectory(); + await Promise.all( + [directory, path.join(home, 'Library/LaunchAgents'), path.join(home, 'Library/Logs')].map( + filename => fs.mkdir(filename, { recursive: true }) + ) + ); + await fs.copyFile(runnerPath, path.join(directory, 'run')); + const plist = await fs.readFile(plistPath, 'utf8'); + await fs.writeFile( + path.join(home, `Library/LaunchAgents/${LABEL}.plist`), + plist + .replaceAll('__HOME__', escapeXml(home)) + .replaceAll('__NODE__', escapeXml(process.execPath)) + ); +} diff --git a/packages/vm-tools/src/main.ts b/packages/vm-tools/src/main.ts new file mode 100644 index 0000000000..2f8d50b76a --- /dev/null +++ b/packages/vm-tools/src/main.ts @@ -0,0 +1,29 @@ +import path from 'node:path'; + +import { runStartupScriptOncePerBootAsync } from './commands/daemon'; +import { installStartupDaemonAsync } from './commands/install'; + +export async function runCliAsync(): Promise { + try { + switch (process.argv[2]) { + case 'install': + await installStartupDaemonAsync( + __filename, + path.join(__dirname, '../resources/com.expo.vm-tools.plist') + ); + break; + case 'daemon': + process.exitCode = await runStartupScriptOncePerBootAsync(); + break; + default: + throw new Error('Usage: vm-tools '); + } + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} + +if (require.main === module) { + void runCliAsync(); +} diff --git a/packages/vm-tools/src/utils/state.ts b/packages/vm-tools/src/utils/state.ts new file mode 100644 index 0000000000..c8d4da72d6 --- /dev/null +++ b/packages/vm-tools/src/utils/state.ts @@ -0,0 +1,6 @@ +import os from 'node:os'; +import path from 'node:path'; + +export function getVmStateDirectory(): string { + return path.join(os.homedir(), '.expo/vm-tools'); +} diff --git a/packages/vm-tools/tsconfig.json b/packages/vm-tools/tsconfig.json new file mode 100644 index 0000000000..b331298a1c --- /dev/null +++ b/packages/vm-tools/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "ignoreDeprecations": "6.0", + "target": "es2022", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node", "jest"], + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/yarn.lock b/yarn.lock index c07393872b..a9da453c83 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3200,6 +3200,21 @@ __metadata: languageName: unknown linkType: soft +"@expo/vm-tools@workspace:packages/vm-tools": + version: 0.0.0-use.local + resolution: "@expo/vm-tools@workspace:packages/vm-tools" + dependencies: + "@types/jest": "npm:^29.5.12" + "@types/node": "npm:22.20.5" + "@vercel/ncc": "npm:^0.38.1" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.4.12" + typescript: "npm:^6.0.3" + bin: + vm-tools: bin/vm-tools + languageName: unknown + linkType: soft + "@expo/worker@workspace:packages/worker": version: 0.0.0-use.local resolution: "@expo/worker@workspace:packages/worker"