diff --git a/README.md b/README.md index 2993a1f2..e397da09 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ $ aio runtime --help * [`aio runtime rule status NAME`](#aio-runtime-rule-status-name) * [`aio runtime rule update NAME TRIGGER ACTION`](#aio-runtime-rule-update-name-trigger-action) * [`aio runtime sandbox`](#aio-runtime-sandbox) +* [`aio runtime sandbox exec [COMMAND]`](#aio-runtime-sandbox-exec-command) * [`aio runtime sandbox run`](#aio-runtime-sandbox-run) * [`aio runtime trigger`](#aio-runtime-trigger) * [`aio runtime trigger create TRIGGERNAME`](#aio-runtime-trigger-create-triggername) @@ -2184,6 +2185,77 @@ ALIASES _See code: [src/commands/runtime/sandbox/index.js](https://github.com/adobe/aio-cli-plugin-runtime/blob/8.4.0/src/commands/runtime/sandbox/index.js)_ +## `aio runtime sandbox exec [COMMAND]` + +[Alpha] Sandboxes are in a closed alpha. Your namespace must have + +``` +USAGE + $ aio runtime sandbox exec [COMMAND] [--cert] [--key] [--apiversion] [--apihost] [-u] [-i] [--debug ] [-v] + [--version] [--help] [-n ] [-e ...] [-p ...] [--max-lifetime ] [--command-timeout + ] [--fail-fast] [-f ] + +ARGUMENTS + [COMMAND] command to run in the sandbox (quote multi-word commands) + +FLAGS + -e, --egress=... egress rule in host:port[:protocol][|METHOD:path] format, or "allow-all" (repeatable) + -f, --file= path to a file with a newline-separated list of commands to run + -i, --insecure bypass certificate check + -n, --name= [default: aio-sandbox] sandbox name + -p, --port=... Port to expose via a preview URL (repeatable) + -u, --auth [env: WHISK_AUTH] whisk auth + -v, --verbose Verbose output + --apihost [env: WHISK_APIHOST] whisk API host + --apiversion [env: WHISK_APIVERSION] whisk API version + --cert client cert + --command-timeout= [default: 30000] per-command timeout in milliseconds + --debug= Debug level output + --fail-fast stop execution when a command returns a non-zero exit code + --help Show help + --key client key + --max-lifetime= [default: 3600] maximum sandbox lifetime in seconds + --version Show version + +DESCRIPTION + + [Alpha] Sandboxes are in a closed alpha. Your namespace must have + sandboxes enabled before you can use this command; contact Adobe to request + access. + + Create a sandbox and run one or more commands non-interactively, then destroy it. + + Provide a one-shot command as a quoted argument and/or a newline-separated list + of commands via --file. When both are given, the one-shot command runs first, + followed by the list in order. + + Each command runs in a fresh process. Shell state (working directory, env + exports) does not persist between commands. Chain commands to work around + this: cd mydir && npm install + + By default every command runs and the process exits with the last non-zero + exit code. Use --fail-fast to stop at the first failure. Each command is + capped at --command-timeout milliseconds (default 30000). + + For an interactive session, use "aio runtime sandbox run" instead. + +ALIASES + $ aio rt sandbox exec + +EXAMPLES + $ aio runtime sandbox exec "node --version" + + $ aio runtime sandbox exec --file commands.txt + + $ aio runtime sandbox exec "node --version" --file commands.txt + + $ aio runtime sandbox exec -e allow-all -p 5173 --file commands.txt + + $ aio runtime sandbox exec --fail-fast --command-timeout 120000 --file commands.txt +``` + +_See code: [src/commands/runtime/sandbox/exec.js](https://github.com/adobe/aio-cli-plugin-runtime/blob/8.4.0/src/commands/runtime/sandbox/exec.js)_ + ## `aio runtime sandbox run` [Alpha] Sandboxes are in a closed alpha. Your namespace must have diff --git a/src/commands/runtime/sandbox/exec.js b/src/commands/runtime/sandbox/exec.js new file mode 100644 index 00000000..f38a1b92 --- /dev/null +++ b/src/commands/runtime/sandbox/exec.js @@ -0,0 +1,197 @@ +/* +Copyright 2026 Adobe Inc. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const fs = require('node:fs') +const { Sandbox } = require('@adobe/aio-lib-sandbox') +const { Args, Flags } = require('@oclif/core') +const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') +const { + buildNetworkPolicy, + parsePortFlags, + parseEgressFlags, + buildCommandList, + logPolicy, + logPreviewUrls +} = require('../../../sandbox-helpers') + +const DEFAULT_COMMAND_TIMEOUT_MS = 30000 + +class SandboxExec extends RuntimeBaseCommand { + async run () { + const { args, flags } = await this.parse(SandboxExec) + + let listText = '' + if (flags.file) { + try { + listText = this._readFile(flags.file) + } catch (err) { + this._failUsage(`Cannot read --file "${flags.file}": ${err.message || err}`) + return + } + } + const commands = buildCommandList(args.command, listText) + + if (commands.length === 0) { + this._failUsage('No commands to run. Pass a quoted command (e.g. \'node --version\') and/or a command list with --file. For an interactive session use "aio runtime sandbox run".') + return + } + + let sandbox + try { + const policy = buildNetworkPolicy(flags.egress) + const ports = parsePortFlags(flags.port) + const options = await this.getOptions() + + this.log('\nCreating sandbox...') + sandbox = await Sandbox.create({ + apiHost: options.apihost, + namespace: options.namespace, + auth: options.api_key, + name: flags.name, + maxLifetime: flags['max-lifetime'], + envs: {}, + ...(ports && { ports }), + ...(policy && { policy }) + }) + this.log(`Created: ${sandbox.id}`) + + logPolicy(policy, msg => this.log(msg)) + await logPreviewUrls(sandbox, ports, msg => this.log(msg)) + + await this._runCommands(sandbox, commands, flags) + } catch (err) { + await this.handleError('failed to exec in sandbox', err) + } finally { + if (sandbox) { + try { + await sandbox.destroy() + this.log('Sandbox destroyed.') + } catch (destroyErr) { + this.log(`failed to destroy sandbox: ${destroyErr.message || destroyErr}`) + } + } + } + } + + _readFile (filePath) { + return fs.readFileSync(filePath, 'utf8') + } + + _failUsage (message) { + process.stderr.write(`${message}\n`) + process.exitCode = 2 + } + + async _runCommands (sandbox, commands, flags) { + const timeout = flags['command-timeout'] + for (const cmd of commands) { + this.log(`\n$ ${cmd}`) + const result = await sandbox.exec(cmd, { timeout }) + if (result.stdout) process.stdout.write(result.stdout) + if (result.stderr) process.stderr.write(result.stderr) + this.log(`[exit: ${result.exitCode}]`) + + if (result.exitCode !== 0) { + process.exitCode = result.exitCode + if (flags['fail-fast']) { + this.log('Stopping: command exited non-zero (--fail-fast).') + return + } + } + } + } +} + +SandboxExec.description = ` +[Alpha] Sandboxes are in a closed alpha. Your namespace must have +sandboxes enabled before you can use this command; contact Adobe to request +access. + +Create a sandbox and run one or more commands non-interactively, then destroy it. + +Provide a one-shot command as a quoted argument and/or a newline-separated list +of commands via --file. When both are given, the one-shot command runs first, +followed by the list in order. + +Each command runs in a fresh process. Shell state (working directory, env +exports) does not persist between commands. Chain commands to work around +this: cd mydir && npm install + +By default every command runs and the process exits with the last non-zero +exit code. Use --fail-fast to stop at the first failure. Each command is +capped at --command-timeout milliseconds (default 30000). + +For an interactive session, use "aio runtime sandbox run" instead.` + +SandboxExec.flags = { + ...RuntimeBaseCommand.flags, + name: Flags.string({ + char: 'n', + description: 'sandbox name', + default: 'aio-sandbox' + }), + egress: Flags.string({ + char: 'e', + description: 'egress rule in host:port[:protocol][|METHOD:path] format, or "allow-all" (repeatable)', + multiple: true, + // non-greedy so a trailing positional command is not swallowed as an egress value + multipleNonGreedy: true + }), + port: Flags.string({ + char: 'p', + description: 'Port to expose via a preview URL (repeatable)', + multiple: true, + // non-greedy so a trailing positional command is not swallowed as a port value + multipleNonGreedy: true + }), + 'max-lifetime': Flags.integer({ + description: 'maximum sandbox lifetime in seconds', + default: 3600 + }), + 'command-timeout': Flags.integer({ + description: 'per-command timeout in milliseconds', + default: DEFAULT_COMMAND_TIMEOUT_MS + }), + 'fail-fast': Flags.boolean({ + description: 'stop execution when a command returns a non-zero exit code', + default: false + }), + file: Flags.string({ + char: 'f', + description: 'path to a file with a newline-separated list of commands to run' + }) +} + +SandboxExec.args = { + command: Args.string({ + description: 'command to run in the sandbox (quote multi-word commands)', + required: false + }) +} + +SandboxExec.examples = [ + '<%= config.bin %> <%= command.id %> "node --version"', + '<%= config.bin %> <%= command.id %> --file commands.txt', + '<%= config.bin %> <%= command.id %> "node --version" --file commands.txt', + '<%= config.bin %> <%= command.id %> -e allow-all -p 5173 --file commands.txt', + '<%= config.bin %> <%= command.id %> --fail-fast --command-timeout 120000 --file commands.txt' +] + +SandboxExec.aliases = ['rt:sandbox:exec'] + +// exposed for testing +SandboxExec.parseEgressFlags = parseEgressFlags +SandboxExec.parsePortFlags = parsePortFlags +SandboxExec.buildNetworkPolicy = buildNetworkPolicy +SandboxExec.buildCommandList = buildCommandList + +module.exports = SandboxExec diff --git a/src/commands/runtime/sandbox/run.js b/src/commands/runtime/sandbox/run.js index 445f7110..2f0ccd82 100644 --- a/src/commands/runtime/sandbox/run.js +++ b/src/commands/runtime/sandbox/run.js @@ -18,7 +18,9 @@ const { buildNetworkPolicy, parsePortFlags, parseEgressFlags, - splitArgvAtDoubleDash + splitArgvAtDoubleDash, + logPolicy, + logPreviewUrls } = require('../../../sandbox-helpers') const EXEC_TIMEOUT_MS = 30000 @@ -72,12 +74,12 @@ class SandboxRun extends RuntimeBaseCommand { const { flags } = await this.parse(SandboxRun, cliArgs) if (commandArgs.length > 0) { - this._failUsage('This command only supports interactive use. Omit "-- " and type commands when prompted.') + this._failUsage('This command only supports interactive use. Type commands when prompted, or use "aio runtime sandbox exec" for one-shot or scripted commands.') return } if (process.stdin.isTTY !== true) { - this._failUsage('This command requires an interactive terminal. Piped stdin is not supported.') + this._failUsage('This command requires an interactive terminal. Piped stdin is not supported; use "aio runtime sandbox exec" to run a piped list of commands.') return } @@ -101,8 +103,8 @@ class SandboxRun extends RuntimeBaseCommand { }) this.log(`Created: ${sandbox.id}`) - this._logPolicy(policy) - await this._logPreviewUrls(sandbox, ports) + logPolicy(policy, msg => this.log(msg)) + await logPreviewUrls(sandbox, ports, msg => this.log(msg)) this.log('\nSandbox ready. Type "exit" to destroy and quit.\n') @@ -131,34 +133,6 @@ class SandboxRun extends RuntimeBaseCommand { process.exitCode = 2 } - _logPolicy (policy) { - if (!policy) { - this.log('Network policy: default-deny (DNS + NATS only)') - return - } - if (policy.network.egress === 'allow-all') { - this.log('Network policy: allow-all egress') - return - } - this.log('Network policy: custom egress') - policy.network.egress.forEach(rule => { - const proto = rule.protocol || 'TCP' - const l7 = rule.rules ? ' ' + rule.rules.map(r => `${r.methods.join(',')}:${r.pathPattern}`).join(' ') : '' - this.log(` - ${rule.host}:${rule.port} (${proto})${l7}`) - }) - } - - async _logPreviewUrls (sandbox, ports) { - if (!ports) { - return - } - - this.log('Preview URLs:') - for (const port of ports) { - this.log(` - ${port}: ${await sandbox.getUrl(port)}`) - } - } - async _repl (rl, sandbox) { while (true) { const cmd = await this._ask(rl) diff --git a/src/sandbox-helpers.js b/src/sandbox-helpers.js index 2ddeeef9..2a7e21d1 100644 --- a/src/sandbox-helpers.js +++ b/src/sandbox-helpers.js @@ -134,9 +134,80 @@ function buildNetworkPolicy (egressArgs) { return { network: { egress: parseEgressFlags(egressArgs) } } } +/** + * Build the ordered list of commands for a non-interactive `exec` run. The + * one-shot command runs first, followed by each newline-separated command read + * from piped stdin; blank lines are dropped. Both are complete command strings + * (the one-shot is quoted by the user) and are passed through verbatim. + * + * @param {string|undefined} command the one-shot command, or undefined + * @param {string} stdinText raw piped stdin contents + * @returns {string[]} ordered commands to execute + */ +function buildCommandList (command, stdinText) { + const commands = [] + if (command) { + commands.push(command) + } + if (stdinText) { + for (const line of stdinText.split('\n')) { + const trimmed = line.trim() + if (trimmed) { + commands.push(trimmed) + } + } + } + return commands +} + +/** + * Log a human-readable summary of the sandbox network policy. + * + * @param {object|undefined} policy sandbox policy, or undefined for default-deny + * @param {Function} log logger called with each line + */ +function logPolicy (policy, log) { + if (!policy) { + log('Network policy: default-deny (DNS + NATS only)') + return + } + if (policy.network.egress === 'allow-all') { + log('Network policy: allow-all egress') + return + } + log('Network policy: custom egress') + policy.network.egress.forEach(rule => { + const proto = rule.protocol || 'TCP' + const l7 = rule.rules ? ' ' + rule.rules.map(r => `${r.methods.join(',')}:${r.pathPattern}`).join(' ') : '' + log(` - ${rule.host}:${rule.port} (${proto})${l7}`) + }) +} + +/** + * Log the preview URL for each exposed port, or nothing when no ports. + * + * @param {object} sandbox sandbox instance exposing getUrl(port) + * @param {number[]|undefined} ports exposed ports + * @param {Function} log logger called with each line + * @returns {Promise} resolves once all URLs are logged + */ +async function logPreviewUrls (sandbox, ports, log) { + if (!ports) { + return + } + + log('Preview URLs:') + for (const port of ports) { + log(` - ${port}: ${await sandbox.getUrl(port)}`) + } +} + module.exports = { buildNetworkPolicy, parsePortFlags, parseEgressFlags, - splitArgvAtDoubleDash + splitArgvAtDoubleDash, + buildCommandList, + logPolicy, + logPreviewUrls } diff --git a/test/commands/runtime/sandbox/exec.test.js b/test/commands/runtime/sandbox/exec.test.js new file mode 100644 index 00000000..377b7412 --- /dev/null +++ b/test/commands/runtime/sandbox/exec.test.js @@ -0,0 +1,381 @@ +/* +Copyright 2026 Adobe Inc. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const { stdout, stderr } = require('stdout-stderr') +beforeEach(() => { stdout.start(); stderr.start() }) +afterEach(() => { stdout.stop(); stderr.stop() }) +const TheCommand = require('../../../../src/commands/runtime/sandbox/exec.js') +const RuntimeBaseCommand = require('../../../../src/RuntimeBaseCommand.js') +const { Sandbox } = require('@adobe/aio-lib-sandbox') +const { logPolicy, logPreviewUrls } = require('../../../../src/sandbox-helpers') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +/** + * Build a fake `Sandbox` object suitable for stubbing Sandbox.create resolutions. + * + * @param {object} [overrides] override individual fields + * @returns {object} fake sandbox + */ +function fakeSandbox (overrides = {}) { + return { + id: 'sandbox-123', + exec: jest.fn().mockResolvedValue({ stdout: 'ok\n', stderr: '', exitCode: 0 }), + getUrl: jest.fn(port => Promise.resolve(`https://sandbox-${port}.example.net`)), + destroy: jest.fn().mockResolvedValue({ status: 'destroyed' }), + ...overrides + } +} + +test('exports', async () => { + expect(typeof TheCommand).toEqual('function') + expect(TheCommand.prototype instanceof RuntimeBaseCommand).toBeTruthy() +}) + +test('description', async () => { + expect(TheCommand.description).toBeDefined() + expect(TheCommand.description).toMatch(/non-interactively/) +}) + +test('aliases', async () => { + expect(TheCommand.aliases).toContain('rt:sandbox:exec') +}) + +test('examples', async () => { + expect(TheCommand.examples).toBeInstanceOf(Array) + expect(TheCommand.examples.length).toBeGreaterThan(0) +}) + +test('flags', async () => { + // shared with run + expect(TheCommand.flags.name.char).toBe('n') + expect(TheCommand.flags.name.default).toBe('aio-sandbox') + expect(TheCommand.flags.egress.char).toBe('e') + expect(TheCommand.flags.egress.multiple).toBe(true) + expect(TheCommand.flags.port.char).toBe('p') + expect(TheCommand.flags.port.multiple).toBe(true) + expect(TheCommand.flags['max-lifetime'].default).toBe(3600) + // exec-specific: --command-timeout is the per-command cap, default 30s + expect(TheCommand.flags['command-timeout'].default).toBe(30000) + expect(TheCommand.flags['fail-fast']).toBeDefined() + expect(TheCommand.flags.file.char).toBe('f') + // inherits base flags + expect(TheCommand.flags.apihost).toBeDefined() +}) + +describe('_readFile', () => { + test('reads file contents as utf8', () => { + const command = new TheCommand([]) + const tmp = path.join(os.tmpdir(), `exec-readfile-${Date.now()}.txt`) + fs.writeFileSync(tmp, 'echo hi\n') + try { + expect(command._readFile(tmp)).toBe('echo hi\n') + } finally { + fs.unlinkSync(tmp) + } + }) +}) + +describe('buildCommandList', () => { + test('one-shot only', () => { + expect(TheCommand.buildCommandList('node --version', '')).toEqual(['node --version']) + }) + + test('one-shot command is passed verbatim', () => { + expect(TheCommand.buildCommandList('echo "a b"', '')).toEqual(['echo "a b"']) + }) + + test('piped only, newline-split, blanks dropped', () => { + expect(TheCommand.buildCommandList(undefined, 'a\n\nb\n')).toEqual(['a', 'b']) + }) + + test('piped lines are passed verbatim', () => { + expect(TheCommand.buildCommandList(undefined, 'cd app && npm install\n')).toEqual(['cd app && npm install']) + }) + + test('one-shot runs first, then piped', () => { + expect(TheCommand.buildCommandList('node --version', 'a\nb')).toEqual(['node --version', 'a', 'b']) + }) + + test('empty when nothing provided', () => { + expect(TheCommand.buildCommandList(undefined, '')).toEqual([]) + }) +}) + +describe('logPolicy', () => { + test('logs default-deny when no policy', () => { + const log = jest.fn() + logPolicy(undefined, log) + expect(log).toHaveBeenCalledWith('Network policy: default-deny (DNS + NATS only)') + }) + + test('logs allow-all egress', () => { + const log = jest.fn() + logPolicy({ network: { egress: 'allow-all' } }, log) + expect(log).toHaveBeenCalledWith('Network policy: allow-all egress') + }) + + test('logs custom egress rules', () => { + const log = jest.fn() + logPolicy({ + network: { + egress: [ + { host: 'pypi.org', port: 443 }, + { host: 'api.github.com', port: 443, rules: [{ methods: ['GET'], pathPattern: '/repos/**' }] } + ] + } + }, log) + expect(log).toHaveBeenCalledWith('Network policy: custom egress') + expect(log).toHaveBeenCalledWith(' - pypi.org:443 (TCP)') + expect(log).toHaveBeenCalledWith(' - api.github.com:443 (TCP) GET:/repos/**') + }) +}) + +describe('logPreviewUrls', () => { + test('does nothing when no ports', async () => { + const log = jest.fn() + const sandbox = { getUrl: jest.fn() } + await logPreviewUrls(sandbox, undefined, log) + expect(log).not.toHaveBeenCalled() + expect(sandbox.getUrl).not.toHaveBeenCalled() + }) + + test('logs a preview URL per port', async () => { + const log = jest.fn() + const sandbox = { getUrl: jest.fn(port => Promise.resolve(`https://sandbox-${port}.example.net`)) } + await logPreviewUrls(sandbox, [3000, 8080], log) + expect(log).toHaveBeenCalledWith('Preview URLs:') + expect(log).toHaveBeenCalledWith(' - 3000: https://sandbox-3000.example.net') + expect(log).toHaveBeenCalledWith(' - 8080: https://sandbox-8080.example.net') + }) +}) + +describe('run', () => { + let command + let handleError + let sandbox + const originalExitCode = process.exitCode + + beforeEach(async () => { + command = new TheCommand([]) + handleError = jest.spyOn(command, 'handleError').mockResolvedValue(undefined) + sandbox = fakeSandbox() + Sandbox.create.mockReset() + Sandbox.create.mockResolvedValue(sandbox) + }) + + afterEach(() => { + process.exitCode = originalExitCode + }) + + test('runs a single one-shot command and destroys', async () => { + command.argv = ['node --version'] + sandbox.exec.mockResolvedValueOnce({ stdout: 'v25.9.0\n', stderr: '', exitCode: 0 }) + + await command.run() + + expect(sandbox.exec).toHaveBeenCalledWith('node --version', { timeout: 30000 }) + expect(stdout.output).toMatch('v25.9.0') + expect(stdout.output).toMatch('[exit: 0]') + expect(sandbox.destroy).toHaveBeenCalled() + }) + + test('defaults the per-command timeout to 30s when --command-timeout is omitted', async () => { + command.argv = ['true'] + + await command.run() + + expect(sandbox.exec).toHaveBeenCalledWith('true', { timeout: 30000 }) + }) + + test('--command-timeout overrides the per-command default', async () => { + command.argv = ['--command-timeout', '5000', 'true'] + + await command.run() + + expect(sandbox.exec).toHaveBeenCalledWith('true', { timeout: 5000 }) + }) + + test('--file reads a newline-separated command list', async () => { + jest.spyOn(command, '_readFile').mockReturnValue('echo a\necho b\n') + command.argv = ['--file', 'cmds.txt'] + + await command.run() + + expect(command._readFile).toHaveBeenCalledWith('cmds.txt') + expect(sandbox.exec).toHaveBeenNthCalledWith(1, 'echo a', { timeout: 30000 }) + expect(sandbox.exec).toHaveBeenNthCalledWith(2, 'echo b', { timeout: 30000 }) + }) + + test('--file with a one-shot command runs the one-shot first', async () => { + jest.spyOn(command, '_readFile').mockReturnValue('echo from-file\n') + command.argv = ['--file', 'cmds.txt', 'echo from-arg'] + + await command.run() + + expect(sandbox.exec).toHaveBeenNthCalledWith(1, 'echo from-arg', { timeout: 30000 }) + expect(sandbox.exec).toHaveBeenNthCalledWith(2, 'echo from-file', { timeout: 30000 }) + }) + + test('-f is the short flag for --file', async () => { + jest.spyOn(command, '_readFile').mockReturnValue('echo from-file\n') + command.argv = ['-f', 'cmds.txt'] + + await command.run() + + expect(command._readFile).toHaveBeenCalledWith('cmds.txt') + expect(sandbox.exec).toHaveBeenCalledTimes(1) + expect(sandbox.exec).toHaveBeenCalledWith('echo from-file', { timeout: 30000 }) + }) + + test('--file with an unreadable path errors before creating a sandbox', async () => { + jest.spyOn(command, '_readFile').mockImplementation(() => { throw new Error('ENOENT: no such file') }) + command.argv = ['--file', 'missing.txt'] + + await command.run() + + expect(stderr.output).toMatch(/Cannot read --file/) + expect(process.exitCode).toBe(2) + expect(Sandbox.create).not.toHaveBeenCalled() + }) + + test('--file read error without .message stringifies the thrown value', async () => { + jest.spyOn(command, '_readFile').mockImplementation(() => { throw 'plain failure' }) // eslint-disable-line no-throw-literal + command.argv = ['--file', 'missing.txt'] + + await command.run() + + expect(stderr.output).toMatch(/Cannot read --file "missing.txt": plain failure/) + expect(process.exitCode).toBe(2) + }) + + test('--fail-fast stops on first non-zero exit and sets exit code', async () => { + jest.spyOn(command, '_readFile').mockReturnValue('bad\ngood\n') + command.argv = ['--fail-fast', '--file', 'cmds.txt'] + sandbox.exec.mockResolvedValueOnce({ stdout: '', stderr: 'nope\n', exitCode: 1 }) + + await command.run() + + expect(sandbox.exec).toHaveBeenCalledTimes(1) + expect(sandbox.exec).toHaveBeenCalledWith('bad', { timeout: 30000 }) + expect(process.exitCode).toBe(1) + expect(sandbox.destroy).toHaveBeenCalled() + }) + + test('without --fail-fast runs all and exits with last non-zero code', async () => { + jest.spyOn(command, '_readFile').mockReturnValue('first\nsecond\nthird\n') + command.argv = ['--file', 'cmds.txt'] + sandbox.exec + .mockResolvedValueOnce({ stdout: '', stderr: 'e1\n', exitCode: 3 }) + .mockResolvedValueOnce({ stdout: 'ok\n', stderr: '', exitCode: 0 }) + .mockResolvedValueOnce({ stdout: '', stderr: 'e2\n', exitCode: 5 }) + + await command.run() + + expect(sandbox.exec).toHaveBeenCalledTimes(3) + expect(process.exitCode).toBe(5) + }) + + test('exits 0 when all commands succeed', async () => { + jest.spyOn(command, '_readFile').mockReturnValue('a\nb\n') + command.argv = ['--file', 'cmds.txt'] + process.exitCode = 0 + + await command.run() + + expect(sandbox.exec).toHaveBeenCalledTimes(2) + expect(process.exitCode).toBe(0) + }) + + test('errors when no command and no --file is provided', async () => { + command.argv = [] + + await command.run() + + expect(stderr.output).toMatch(/no commands/i) + expect(stderr.output).toMatch(/sandbox run/) + expect(process.exitCode).toBe(2) + expect(Sandbox.create).not.toHaveBeenCalled() + expect(sandbox.destroy).not.toHaveBeenCalled() + }) + + test('forwards name, egress, port, max-lifetime to Sandbox.create', async () => { + command.argv = ['-n', 'mybox', '--max-lifetime', '600', '-e', 'allow-all', '-p', '5173', 'true'] + + await command.run() + + expect(Sandbox.create).toHaveBeenCalledWith(expect.objectContaining({ + name: 'mybox', + maxLifetime: 600, + ports: [5173], + policy: { network: { egress: 'allow-all' } } + })) + expect(sandbox.getUrl).toHaveBeenCalledWith(5173) + }) + + test('logs custom egress rules', async () => { + command.argv = ['-e', 'pypi.org:443', '-e', 'api.github.com:443|GET:/repos/**', 'true'] + + await command.run() + + expect(stdout.output).toMatch('Network policy: custom egress') + expect(stdout.output).toMatch('pypi.org:443 (TCP)') + expect(stdout.output).toMatch('api.github.com:443 (TCP) GET:/repos/**') + }) + + test('logs default-deny policy when no egress', async () => { + command.argv = ['true'] + + await command.run() + + expect(stdout.output).toMatch('Network policy: default-deny') + }) + + test('reports command stderr', async () => { + command.argv = ['cat missing'] + sandbox.exec.mockResolvedValueOnce({ stdout: '', stderr: 'no such file\n', exitCode: 1 }) + + await command.run() + + expect(stderr.output).toMatch('no such file') + expect(stdout.output).toMatch('[exit: 1]') + }) + + test('routes create errors through handleError and skips destroy', async () => { + Sandbox.create.mockRejectedValue(new Error('boom')) + command.argv = ['true'] + + await command.run() + + expect(handleError).toHaveBeenCalledWith('failed to exec in sandbox', expect.objectContaining({ message: 'boom' })) + expect(sandbox.destroy).not.toHaveBeenCalled() + }) + + test('logs a message when destroy fails', async () => { + sandbox.destroy.mockRejectedValue(new Error('destroy failed')) + command.argv = ['true'] + + await command.run() + + expect(stdout.output).toMatch('failed to destroy sandbox: destroy failed') + }) + + test('logs a stringified value when destroy rejects without .message', async () => { + sandbox.destroy.mockRejectedValue('plain reason') + command.argv = ['true'] + + await command.run() + + expect(stdout.output).toMatch('failed to destroy sandbox: plain reason') + }) +}) diff --git a/test/commands/runtime/sandbox/run.test.js b/test/commands/runtime/sandbox/run.test.js index 57f5b6cb..dc957310 100644 --- a/test/commands/runtime/sandbox/run.test.js +++ b/test/commands/runtime/sandbox/run.test.js @@ -304,6 +304,7 @@ describe('run', () => { await command.run() expect(stderr.output).toMatch('only supports interactive use') + expect(stderr.output).toMatch('aio runtime sandbox exec') expect(stderr.output).not.toMatch('CLIError') expect(process.exitCode).toBe(2) expect(handleError).not.toHaveBeenCalled() @@ -322,6 +323,7 @@ describe('run', () => { await command.run() expect(stderr.output).toMatch('Piped stdin is not supported') + expect(stderr.output).toMatch('aio runtime sandbox exec') expect(stderr.output).not.toMatch('CLIError') expect(process.exitCode).toBe(2) expect(handleError).not.toHaveBeenCalled() @@ -338,6 +340,7 @@ describe('run', () => { await command.run() expect(stderr.output).toMatch('Piped stdin is not supported') + expect(stderr.output).toMatch('aio runtime sandbox exec') expect(stderr.output).not.toMatch('CLIError') expect(process.exitCode).toBe(2) expect(handleError).not.toHaveBeenCalled()