diff --git a/lib/actions/ssh.ts b/lib/actions/ssh.ts index 065567c4..fee22587 100644 --- a/lib/actions/ssh.ts +++ b/lib/actions/ssh.ts @@ -230,12 +230,11 @@ export const ssh: CommandDefinition< const applicationOrDevice = params.applicationOrDevice_raw || params.applicationOrDevice; const { ExpectedError } = await import('../errors'); - const { getProxyConfig, which, whichSpawn } = await import( - '../utils/helpers' - ); + const { getProxyConfig, which } = await import('../utils/helpers'); const { checkLoggedIn, getOnlineTargetUuid } = await import( '../utils/patterns' ); + const { spawnSshAndExitOnError } = await import('../utils/ssh'); const sdk = getBalenaSdk(); const verbose = options.verbose === true; @@ -356,6 +355,6 @@ export const ssh: CommandDefinition< username: username!, }); - await whichSpawn('ssh', command); + return spawnSshAndExitOnError(command); }, }; diff --git a/lib/utils/device/ssh.ts b/lib/utils/device/ssh.ts index ede77d9b..77e4bd3d 100644 --- a/lib/utils/device/ssh.ts +++ b/lib/utils/device/ssh.ts @@ -28,7 +28,7 @@ export async function performLocalDeviceSSH( opts: DeviceSSHOpts, ): Promise { const reduce = await import('lodash/reduce'); - const { whichSpawn } = await import('../helpers'); + const { spawnSshAndExitOnError } = await import('../ssh'); const { ExpectedError } = await import('../../errors'); const { stripIndent } = await import('common-tags'); const { isatty } = await import('tty'); @@ -107,7 +107,7 @@ export async function performLocalDeviceSSH( command = `${deviceContainerEngineBinary} exec -i ${ttyFlag} ${containerId} ${shellCmd}`; } - return whichSpawn('ssh', [ + return spawnSshAndExitOnError([ ...(opts.verbose ? ['-vvv'] : []), '-t', ...['-p', opts.port ? opts.port.toString() : '22222'], diff --git a/lib/utils/helpers.ts b/lib/utils/helpers.ts index cb3ab9f2..207f5696 100644 --- a/lib/utils/helpers.ts +++ b/lib/utils/helpers.ts @@ -380,36 +380,50 @@ export async function which( /** * Call which(programName) and spawn() with the given arguments. - * Reject the promise if the process exit code is not zero. + * + * If returnExitCodeOrSignal is true, the returned promise will resolve to + * an array [code, signal] with the child process exit code number or exit + * signal string respectively (as provided by the spawn close event). + * + * If returnExitCodeOrSignal is false, the returned promise will reject with + * a custom error if the child process returns a non-zero exit code or a + * non-empty signal string (as reported by the spawn close event). + * + * In either case and if spawn itself emits an error event or fails synchronously, + * the returned promise will reject with a custom error that includes the error + * message of spawn's error. */ export async function whichSpawn( programName: string, args: string[], options: SpawnOptions = { stdio: 'inherit' }, -): Promise { + returnExitCodeOrSignal = false, +): Promise<[number | undefined, string | undefined]> { const program = await which(programName); if (process.env.DEBUG) { console.error(`[debug] [${program}, ${args.join(', ')}]`); } let error: Error | undefined; let exitCode: number | undefined; + let exitSignal: string | undefined; try { - exitCode = await new Promise((resolve, reject) => { + [exitCode, exitSignal] = await new Promise((resolve, reject) => { spawn(program, args, options) .on('error', reject) - .on('close', resolve); + .on('close', (code, signal) => resolve([code, signal])); }); } catch (err) { error = err; } - if (error || exitCode) { + if (error || (!returnExitCodeOrSignal && (exitCode || exitSignal))) { const msg = [ - `${programName} failed with exit code ${exitCode}:`, + `${programName} failed with exit code=${exitCode} signal=${exitSignal}:`, `[${program}, ${args.join(', ')}]`, ...(error ? [`${error}`] : []), ]; throw new Error(msg.join('\n')); } + return [exitCode, exitSignal]; } export interface ProxyConfig { diff --git a/lib/utils/ssh.ts b/lib/utils/ssh.ts index aef31f9b..ac22dbbe 100644 --- a/lib/utils/ssh.ts +++ b/lib/utils/ssh.ts @@ -111,3 +111,49 @@ export async function execBuffered( export const getDeviceOsRelease = _.memoize(async (deviceIp: string) => execBuffered(deviceIp, 'cat /etc/os-release'), ); + +// TODO: consolidate the various forms of executing ssh child processes +// in the CLI, like exec and spawn, starting with the files: +// lib/actions/ssh.ts +// lib/utils/ssh.ts +// lib/utils/device/ssh.ts + +/** + * Obtain the full path for ssh using which, then spawn a child process. + * - If the child process returns error code 0, return the function normally + * (do not call process.exit()). + * - If the child process returns a non-zero error code, print a single-line + * warning message and call process.exit(code) with the same non-zero error + * code. + * - If the child process is terminated by a process signal, print a + * single-line warning message and call process.exit(1). + */ +export async function spawnSshAndExitOnError( + args: string[], + options?: import('child_process').SpawnOptions, +) { + const { whichSpawn } = await import('./helpers'); + const [exitCode, exitSignal] = await whichSpawn( + 'ssh', + args, + options, + true, // returnExitCodeOrSignal + ); + if (exitCode || exitSignal) { + // ssh returns a wide range of exit codes, including return codes of + // interactive shells. For example, if the user types CTRL-C on an + // interactive shell and then `exit`, ssh returns error code 130. + // Another example, typing "exit 1" on an interactive shell causes ssh + // to return exit code 1. In these cases, print a short one-line warning + // message, and exits the CLI process with the same error code. + const codeMsg = exitSignal + ? `was terminated with signal "${exitSignal}"` + : `exited with non-zero code "${exitCode}"`; + console.error(`Warning: ssh process ${codeMsg}`); + // TODO: avoid process.exit by refactoring CLI error handling to allow + // exiting with an error code and single-line warning "without a fuss" + // about contacting support and filing Github issues. (ExpectedError + // does not currently devlivers that.) + process.exit(exitCode || 1); + } +}