Merge pull request #1689 from balena-io/add-ssh-tty-option

balena ssh: handle exit codes and add `-t` option (force TTY allocation)
This commit is contained in:
Paulo Castro 2020-03-31 16:53:51 +01:00 committed by GitHub
commit 07c09c5f89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 87 additions and 14 deletions

View File

@ -1210,6 +1210,10 @@ support) please check:
SSH server port number (default 22222) if the target is an IP address or .local SSH server port number (default 22222) if the target is an IP address or .local
hostname. Otherwise, port number for the balenaCloud gateway (default 22). hostname. Otherwise, port number for the balenaCloud gateway (default 22).
#### --tty, -t
Force pseudo-terminal allocation (bypass TTY autodetection for stdin)
#### --verbose, -v #### --verbose, -v
Increase verbosity Increase verbosity

View File

@ -169,6 +169,7 @@ export const ssh: CommandDefinition<
{ {
port: string; port: string;
service: string; service: string;
tty: boolean;
verbose: true | undefined; verbose: true | undefined;
noProxy: boolean; noProxy: boolean;
} }
@ -214,6 +215,13 @@ export const ssh: CommandDefinition<
hostname. Otherwise, port number for the balenaCloud gateway (default 22).`, hostname. Otherwise, port number for the balenaCloud gateway (default 22).`,
alias: 'p', alias: 'p',
}, },
{
signature: 'tty',
boolean: true,
description:
'Force pseudo-terminal allocation (bypass TTY autodetection for stdin)',
alias: 't',
},
{ {
signature: 'verbose', signature: 'verbose',
boolean: true, boolean: true,
@ -230,12 +238,11 @@ export const ssh: CommandDefinition<
const applicationOrDevice = const applicationOrDevice =
params.applicationOrDevice_raw || params.applicationOrDevice; params.applicationOrDevice_raw || params.applicationOrDevice;
const { ExpectedError } = await import('../errors'); const { ExpectedError } = await import('../errors');
const { getProxyConfig, which, whichSpawn } = await import( const { getProxyConfig, which } = await import('../utils/helpers');
'../utils/helpers'
);
const { checkLoggedIn, getOnlineTargetUuid } = await import( const { checkLoggedIn, getOnlineTargetUuid } = await import(
'../utils/patterns' '../utils/patterns'
); );
const { spawnSshAndExitOnError } = await import('../utils/ssh');
const sdk = getBalenaSdk(); const sdk = getBalenaSdk();
const verbose = options.verbose === true; const verbose = options.verbose === true;
@ -252,6 +259,7 @@ export const ssh: CommandDefinition<
return await performLocalDeviceSSH({ return await performLocalDeviceSSH({
address: applicationOrDevice, address: applicationOrDevice,
port, port,
forceTTY: options.tty === true,
verbose, verbose,
service: params.serviceName, service: params.serviceName,
}); });
@ -356,6 +364,6 @@ export const ssh: CommandDefinition<
username: username!, username: username!,
}); });
await whichSpawn('ssh', command); return spawnSshAndExitOnError(command);
}, },
}; };

View File

@ -18,6 +18,7 @@ import { ContainerInfo } from 'dockerode';
export interface DeviceSSHOpts { export interface DeviceSSHOpts {
address: string; address: string;
port?: number; port?: number;
forceTTY?: boolean;
verbose: boolean; verbose: boolean;
service?: string; service?: string;
} }
@ -28,10 +29,9 @@ export async function performLocalDeviceSSH(
opts: DeviceSSHOpts, opts: DeviceSSHOpts,
): Promise<void> { ): Promise<void> {
const reduce = await import('lodash/reduce'); const reduce = await import('lodash/reduce');
const { whichSpawn } = await import('../helpers'); const { spawnSshAndExitOnError } = await import('../ssh');
const { ExpectedError } = await import('../../errors'); const { ExpectedError } = await import('../../errors');
const { stripIndent } = await import('common-tags'); const { stripIndent } = await import('common-tags');
const { isatty } = await import('tty');
let command = ''; let command = '';
@ -103,11 +103,12 @@ export async function performLocalDeviceSSH(
// echo 'ls -la; exit;' | balena ssh 192.168.0.20 service1 // echo 'ls -la; exit;' | balena ssh 192.168.0.20 service1
// See https://www.balena.io/blog/balena-monthly-roundup-january-2020/#charliestipsntricks // See https://www.balena.io/blog/balena-monthly-roundup-january-2020/#charliestipsntricks
// https://assets.balena.io/newsletter/2020-01/pipe.png // https://assets.balena.io/newsletter/2020-01/pipe.png
const ttyFlag = isatty(0) ? '-t' : ''; const isTTY = !!opts.forceTTY || (await import('tty')).isatty(0);
const ttyFlag = isTTY ? '-t' : '';
command = `${deviceContainerEngineBinary} exec -i ${ttyFlag} ${containerId} ${shellCmd}`; command = `${deviceContainerEngineBinary} exec -i ${ttyFlag} ${containerId} ${shellCmd}`;
} }
return whichSpawn('ssh', [ return spawnSshAndExitOnError([
...(opts.verbose ? ['-vvv'] : []), ...(opts.verbose ? ['-vvv'] : []),
'-t', '-t',
...['-p', opts.port ? opts.port.toString() : '22222'], ...['-p', opts.port ? opts.port.toString() : '22222'],

View File

@ -380,36 +380,50 @@ export async function which(
/** /**
* Call which(programName) and spawn() with the given arguments. * 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( export async function whichSpawn(
programName: string, programName: string,
args: string[], args: string[],
options: SpawnOptions = { stdio: 'inherit' }, options: SpawnOptions = { stdio: 'inherit' },
): Promise<void> { returnExitCodeOrSignal = false,
): Promise<[number | undefined, string | undefined]> {
const program = await which(programName); const program = await which(programName);
if (process.env.DEBUG) { if (process.env.DEBUG) {
console.error(`[debug] [${program}, ${args.join(', ')}]`); console.error(`[debug] [${program}, ${args.join(', ')}]`);
} }
let error: Error | undefined; let error: Error | undefined;
let exitCode: number | undefined; let exitCode: number | undefined;
let exitSignal: string | undefined;
try { try {
exitCode = await new Promise<number>((resolve, reject) => { [exitCode, exitSignal] = await new Promise((resolve, reject) => {
spawn(program, args, options) spawn(program, args, options)
.on('error', reject) .on('error', reject)
.on('close', resolve); .on('close', (code, signal) => resolve([code, signal]));
}); });
} catch (err) { } catch (err) {
error = err; error = err;
} }
if (error || exitCode) { if (error || (!returnExitCodeOrSignal && (exitCode || exitSignal))) {
const msg = [ const msg = [
`${programName} failed with exit code ${exitCode}:`, `${programName} failed with exit code=${exitCode} signal=${exitSignal}:`,
`[${program}, ${args.join(', ')}]`, `[${program}, ${args.join(', ')}]`,
...(error ? [`${error}`] : []), ...(error ? [`${error}`] : []),
]; ];
throw new Error(msg.join('\n')); throw new Error(msg.join('\n'));
} }
return [exitCode, exitSignal];
} }
export interface ProxyConfig { export interface ProxyConfig {

View File

@ -111,3 +111,49 @@ export async function execBuffered(
export const getDeviceOsRelease = _.memoize(async (deviceIp: string) => export const getDeviceOsRelease = _.memoize(async (deviceIp: string) =>
execBuffered(deviceIp, 'cat /etc/os-release'), 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);
}
}