balena-cli/lib/utils/helpers.ts

632 lines
21 KiB
TypeScript
Raw Normal View History

/*
Copyright 2016-2021 Balena Ltd.
Licensed 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 CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type { InitializeEmitter, OperationState } from 'balena-device-init';
import type * as BalenaSdk from 'balena-sdk';
import * as _ from 'lodash';
import { promisify } from 'util';
2020-02-28 15:25:12 +00:00
import { getBalenaSdk, getChalk, getVisuals } from './lazy';
export function getGroupDefaults(group: {
options: Array<{ name: string; default: string | number }>;
}): { [name: string]: string | number | undefined } {
return _.chain(group)
.get('options')
.map((question) => [question.name, question.default])
.fromPairs()
.value();
}
export function stateToString(state: OperationState) {
const percentage = _.padStart(`${state.percentage}`, 3, '0');
2020-02-28 15:25:12 +00:00
const chalk = getChalk();
const result = `${chalk.blue(percentage + '%')} ${chalk.cyan(
state.operation.command,
)}`;
switch (state.operation.command) {
case 'copy':
return `${result} ${state.operation.from.path} -> ${state.operation.to.path}`;
case 'replace':
return `${result} ${state.operation.file.path}, ${state.operation.copy} -> ${state.operation.replace}`;
case 'run-script':
return `${result} ${state.operation.script}`;
default:
throw new Error(`Unsupported operation: ${state.operation.command}`);
}
}
/**
* Execute a child process with admin / superuser privileges, prompting the user for
* elevation as needed, and taking care of shell-escaping arguments in a suitable way
* for Windows and Linux/Mac.
*
* @param command Unescaped array of command and args to be executed. If isCLIcmd is
* true, the command should not include the 'node' or 'balena' components, for example:
* ['internal', 'osinit', ...]. This function will add argv[0] and argv[1] as needed
* (taking process.pkg into account -- CLI standalone zip package), and will also
* shell-escape the arguments as needed, taking into account the differences between
* bash/sh and the Windows cmd.exe in relation to escape characters.
* @param msg Optional message for the user, before the password prompt
* @param stderr Optional stream to which stderr should be piped
* @param isCLIcmd (default: true) Whether the command array is a balena CLI command
* (e.g. ['internal', 'osinit', ...]), in which case process.argv[0] and argv[1] are
* added as necessary, depending on whether the CLI is running as a standalone zip
* package (with Node built in).
*/
export async function sudo(
command: string[],
{
stderr,
msg,
isCLIcmd,
}: { stderr?: NodeJS.WritableStream; msg?: string; isCLIcmd?: boolean } = {},
) {
const { executeWithPrivileges } = await import('./sudo');
if (process.platform !== 'win32') {
console.log(
msg ||
'Admin privileges required: you may be asked for your computer password to continue.',
);
}
isCLIcmd ??= true;
await executeWithPrivileges(command, stderr, isCLIcmd);
}
export function runCommand<T>(commandArgs: string[]): Promise<T> {
const {
isSubcommand,
} = require('../preparser') as typeof import('../preparser');
if (isSubcommand(commandArgs)) {
commandArgs = [
commandArgs[0] + ':' + commandArgs[1],
...commandArgs.slice(2),
];
}
const { run } = require('@oclif/command');
return run(commandArgs);
}
export async function getManifest(
image: string,
deviceType: string,
): Promise<BalenaSdk.DeviceTypeJson.DeviceType> {
const init = await import('balena-device-init');
const manifest = await init.getImageManifest(image);
if (manifest != null) {
return manifest;
}
return getBalenaSdk().models.device.getManifestBySlug(deviceType);
}
export const areDeviceTypesCompatible = (
appDeviceType: BalenaSdk.DeviceTypeJson.DeviceType,
osDeviceType: BalenaSdk.DeviceTypeJson.DeviceType,
) =>
getBalenaSdk().models.os.isArchitectureCompatibleWith(
osDeviceType.arch,
appDeviceType.arch,
) && !!appDeviceType.isDependent === !!osDeviceType.isDependent;
export async function osProgressHandler(step: InitializeEmitter) {
step.on('stdout', process.stdout.write.bind(process.stdout));
step.on('stderr', process.stderr.write.bind(process.stderr));
step.on('state', function (state) {
if (state.operation.command === 'burn') {
return;
}
console.log(exports.stateToString(state));
});
const visuals = getVisuals();
const progressBars = {
write: new visuals.Progress('Writing Device OS'),
check: new visuals.Progress('Validating Device OS'),
};
step.on('burn', (state) => progressBars[state.type].update(state));
await new Promise((resolve, reject) => {
step.on('error', reject);
step.on('end', resolve);
});
}
export function getAppWithArch(
applicationName: string,
): Promise<ApplicationWithDeviceType & { arch: string }> {
return Promise.all([
getApplication(applicationName),
getBalenaSdk().models.config.getDeviceTypes(),
]).then(function ([app, deviceTypes]) {
const config = _.find<BalenaSdk.DeviceTypeJson.DeviceType>(deviceTypes, {
slug: app.is_for__device_type[0].slug,
});
if (!config) {
throw new Error('Could not read application information!');
}
return { ...app, arch: config.arch };
});
}
// TODO: Drop this. The sdk now has this baked in application.get().
function getApplication(
applicationName: string,
): Promise<ApplicationWithDeviceType> {
// Check for an app of the form `user/application`, and send
// that off to a special handler (before importing any modules)
const match = applicationName.split('/');
const extraOptions: BalenaSdk.PineOptions<BalenaSdk.Application> = {
$expand: {
application_type: {
2018-03-20 11:06:44 +00:00
$select: ['name', 'slug', 'supports_multicontainer', 'is_legacy'],
},
is_for__device_type: {
$select: 'slug',
},
},
};
const balena = getBalenaSdk();
if (match.length > 1) {
return balena.models.application.getAppByOwner(
2018-03-20 11:06:44 +00:00
match[1],
match[0],
2018-03-20 11:06:44 +00:00
extraOptions,
) as Promise<ApplicationWithDeviceType>;
}
return balena.models.application.get(
applicationName,
extraOptions,
) as Promise<ApplicationWithDeviceType>;
}
/**
* Returns the boot partition number of the given image.
* @param imagePath Local filesystem path to a balenaOS image file
*/
export async function getBootPartition(imagePath: string): Promise<number> {
const imagefs = await import('balena-image-fs');
const filedisk = await import('file-disk');
const partitioninfo = await import('partitioninfo');
const partitionNumber = await filedisk.withOpenFile(
imagePath,
'r',
async (handle) => {
const disk = new filedisk.FileDisk(handle, true, false, false);
const { partitions } = await partitioninfo.getPartitions(disk, {
includeExtended: false,
getLogical: true,
});
for (const { index } of partitions) {
try {
return await imagefs.interact(disk, index, async (fs) => {
const statAsync = promisify(fs.stat);
const stats = await statAsync('/device-type.json');
if (stats.isFile()) {
return index;
}
});
} catch (error) {
// noop
}
}
},
);
return partitionNumber ?? 1;
}
const second = 1000; // 1000 milliseconds
const minute = 60 * second;
export const delay = promisify(setTimeout);
/**
* Call `func`, and if func() throws an error or returns a promise that
* eventually rejects, retry it `times` many times, each time printing a log
* message including the given `label` and the error that led to retrying.
* Wait initialDelayMs before the first retry. Before each further retry,
* the delay is reduced by the time elapsed since the last retry, and
* increased by multiplying the result by backoffScaler.
* @param func: The function to call and, if needed, retry calling
* @param maxAttempts: How many times (max) to try calling func().
* func() will always be called at least once.
* @param label: Label to include in the retry log message
* @param initialDelayMs: How long to wait before the first retry
* @param backoffScaler: Multiplier to previous wait time
* @param maxSingleDelayMs: Maximum interval between retries
*/
export async function retry<T>({
func,
maxAttempts,
label,
initialDelayMs = 1000,
backoffScaler = 2,
maxSingleDelayMs = 1 * minute,
}: {
func: () => T;
maxAttempts: number;
label: string;
initialDelayMs?: number;
backoffScaler?: number;
maxSingleDelayMs?: number;
}): Promise<T> {
const { SIGINTError } = await import('../errors');
let delayMs = initialDelayMs;
for (let count = 0; count < maxAttempts - 1; count++) {
const lastAttemptMs = Date.now();
try {
return await func();
} catch (err) {
// Don't retry on SIGINT (CTRL-C)
if (err instanceof SIGINTError) {
throw err;
}
if (count) {
// use Math.max to work around system time changes, e.g. DST
const elapsedMs = Math.max(0, Date.now() - lastAttemptMs);
// reduce delayMs by the time elapsed since the last attempt
delayMs = Math.max(initialDelayMs, delayMs - elapsedMs);
// increase delayMs by the backoffScaler factor
delayMs = Math.min(maxSingleDelayMs, delayMs * backoffScaler);
}
const sec = delayMs / 1000;
const secStr = sec < 10 ? sec.toFixed(1) : Math.round(sec).toString();
console.log(
`Retrying "${label}" after ${secStr}s (${count + 1} of ${
maxAttempts - 1
}) due to: ${err}`,
);
await delay(delayMs);
}
}
return await func();
}
/**
* Return a compare(a, b) function suitable for use as the argument for the
* sort() method of an array. That function will use the given manuallySortedArray
* as "sorting guidance":
* - If both a and b are found in the manuallySortedArray, the returned
* compare(a, b) function will follow that ordering.
* - If neither a nor b are found in the manuallySortedArray, the returned
* compare(a, b) function will compare a and b using the standard '<' and
* '>' Javascript operators.
* - If only a or only b are found in the manuallySortedArray, the returned
* compare(a, b) function will treat the element that was found as being
* "smaller than" the not-found element (i.e. found elements appear before
* not-found elements in sorted order).
*
* The equalityFunc(a, x) argument is a function used to compare the items
* being sorted against the items in the manuallySortedArray. For example, if
* equalityFunc was (a, x) => a.startsWith(x), where a is an item being sorted
* and x is an item in the manuallySortedArray, then the manuallySortedArray
* could contain prefix substrings to guide the sorting.
*
* @param manuallySortedArray A pre-sorted array to guide the sorting
* @param equalityFunc An optional function used to compare the items being
* sorted against items in manuallySortedArray. It should return true if
* the two items compare equal, otherwise false. The arguments are the
* same as provided by the standard Javascript array.findIndex() method.
*/
export function getManualSortCompareFunction<T, U = T>(
manuallySortedArray: U[],
equalityFunc: (a: T, x: U, index: number, array: U[]) => boolean,
): (a: T, b: T) => number {
return function (a: T, b: T): number {
const indexA = manuallySortedArray.findIndex((x, index, array) =>
equalityFunc(a, x, index, array),
);
const indexB = manuallySortedArray.findIndex((x, index, array) =>
equalityFunc(b, x, index, array),
);
if (indexA >= 0 && indexB >= 0) {
return indexA - indexB;
} else if (indexA < 0 && indexB < 0) {
return a < b ? -1 : a > b ? 1 : 0;
} else {
return indexA < 0 ? 1 : -1;
}
};
}
/**
* Decide whether the current shell (that executed the CLI process) is a Windows
* 'cmd.exe' shell, including PowerShell, by checking a few environment
* variables.
*/
export function isWindowsComExeShell() {
return (
// neither bash nor sh (e.g. not MSYS, MSYS2, Cygwin, WSL)
process.env.SHELL == null &&
// Windows cmd.exe or PowerShell
process.env.ComSpec != null &&
process.env.ComSpec.endsWith('cmd.exe')
);
}
/**
* Shell argument escaping compatible with sh, bash and Windows cmd.exe.
* @param arg Arguments to be escaped
* @param detectShell Whether to use the SHELL and ComSpec environment
* variables to determine the shell type (sh / bash / cmd.exe). This may be
* useful to detect MSYS / MSYS2, which use bash on Windows. However, if the
* purpose is to use child_process.spawn(..., {shell: true}) and related
* functions, set this to false because child_process.spawn() always uses
* env.ComSpec (cmd.exe) on Windows, even when running on MSYS / MSYS2.
*/
export function shellEscape(args: string[], detectShell = false): string[] {
const isCmdExe = detectShell
? isWindowsComExeShell()
: process.platform === 'win32';
if (isCmdExe) {
return args.map((v) => windowsCmdExeEscapeArg(v));
} else {
const shellEscapeFunc: typeof import('shell-escape') = require('shell-escape');
return args.map((v) => shellEscapeFunc([v]));
}
}
/**
* Escape a string argument to be passed through the Windows cmd.exe shell.
* cmd.exe escaping has some peculiarities, like using the caret character
* instead of a backslash for reserved / metacharacters. Reference:
* https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/
*/
function windowsCmdExeEscapeArg(arg: string): string {
// if it is already double quoted, remove the double quotes
if (arg.length > 1 && arg.startsWith('"') && arg.endsWith('"')) {
arg = arg.slice(1, -1);
}
// escape cmd.exe metacharacters with the '^' (caret) character
arg = arg.replace(/[()%!^<>&|]/g, '^$&');
// duplicate internal double quotes, and double quote overall
return `"${arg.replace(/["]/g, '""')}"`;
}
/**
* Error handling wrapper around the npm `which` package:
* "Like the unix which utility. Finds the first instance of a specified
* executable in the PATH environment variable. Does not cache the results,
* so hash -r is not needed when the PATH changes."
*
* @param program Basename of a program, for example 'ssh'
* @param rejectOnMissing If the program cannot be found, reject the promise
* with an ExpectedError instead of fulfilling it with an empty string.
* @returns The program's full path, e.g. 'C:\WINDOWS\System32\OpenSSH\ssh.EXE'
*/
export async function which(
program: string,
rejectOnMissing = true,
): Promise<string> {
const whichMod = await import('which');
let programPath: string;
try {
programPath = await whichMod(program);
} catch (err) {
if (err.code === 'ENOENT') {
if (rejectOnMissing) {
const { ExpectedError } = await import('../errors');
throw new ExpectedError(
`'${program}' program not found. Is it installed?`,
);
} else {
return '';
}
}
throw err;
}
return programPath;
}
/**
* Call which(programName) and spawn() with the given arguments.
*
* 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: import('child_process').SpawnOptions = { stdio: 'inherit' },
returnExitCodeOrSignal = false,
): Promise<[number | undefined, string | undefined]> {
const { spawn } = await import('child_process');
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, exitSignal] = await new Promise((resolve, reject) => {
spawn(program, args, options)
.on('error', reject)
.on('close', (code, signal) => resolve([code, signal]));
});
} catch (err) {
error = err;
}
if (error || (!returnExitCodeOrSignal && (exitCode || exitSignal))) {
const msg = [
`${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 {
host: string;
port: string;
username?: string;
password?: string;
proxyAuth?: string;
}
/**
* Check whether a proxy has been configured (whether global-tunnel-ng or
* global-agent) and if so, return a ProxyConfig object.
*/
export function getProxyConfig(): ProxyConfig | undefined {
const tunnelNgConfig: any = (global as any).PROXY_CONFIG;
// global-tunnel-ng
if (tunnelNgConfig) {
let username: string | undefined;
let password: string | undefined;
const proxyAuth: string = tunnelNgConfig.proxyAuth;
if (proxyAuth) {
const i = proxyAuth.lastIndexOf(':');
if (i > 0) {
username = proxyAuth.substring(0, i);
password = proxyAuth.substring(i + 1);
}
}
return {
host: tunnelNgConfig.host,
port: `${tunnelNgConfig.port}`,
username,
password,
proxyAuth: tunnelNgConfig.proxyAuth,
};
// global-agent, or no proxy config
} else {
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
if (proxyUrl) {
const { URL } = require('url') as typeof import('url');
let url: URL;
try {
url = new URL(proxyUrl);
} catch (_e) {
return;
}
return {
host: url.hostname,
port: url.port,
username: url.username,
password: url.password,
proxyAuth:
url.username && url.password
? `${url.username}:${url.password}`
: undefined,
};
}
}
}
export const expandForAppName: BalenaSdk.PineOptions<BalenaSdk.Device> = {
$expand: {
belongs_to__application: { $select: 'app_name' },
is_of__device_type: { $select: 'slug' },
is_running__release: { $select: 'commit' },
},
};
/**
* Use the `readline` library on Windows to install SIGINT handlers.
* This appears to be necessary on MSYS / Git for Windows, and also useful
* with PowerShell to avoid the built-in "Terminate batch job? (Y/N)" prompt
* that appears to result in ungraceful / abrupt process termination.
*/
const installReadlineSigintEmitter = _.once(function emitSigint() {
if (process.platform === 'win32') {
const readline = require('readline') as typeof import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.on('SIGINT', () => process.emit('SIGINT' as any));
}
});
/**
* Centralized cross-platform logic to install a SIGINT handler
* @param sigintHandler The handler function
* @param once Whether the handler should be called no more than once
*/
export function addSIGINTHandler(sigintHandler: () => void, once = true) {
installReadlineSigintEmitter();
if (once) {
process.once('SIGINT', sigintHandler);
} else {
process.on('SIGINT', sigintHandler);
}
}
/**
* Call the given task function (which returns a promise) with the given
* arguments, await the returned promise and resolve to the same result.
* While awaiting for that promise, also await for a SIGINT signal (if any),
* with a new SIGINT handler that is automatically removed on return.
* If a SIGINT signal is received while awaiting for the task function,
* immediately return a promise that rejects with SIGINTError.
* @param task An async function to be executed and awaited
* @param theArgs Arguments to be passed to the task function
*/
export async function awaitInterruptibleTask<
T extends (...args: any[]) => Promise<any>
>(task: T, ...theArgs: Parameters<T>): Promise<ReturnType<T>> {
let sigintHandler: () => void = () => undefined;
const sigintPromise = new Promise<T>((_resolve, reject) => {
sigintHandler = () => {
const {
SIGINTError,
} = require('../errors') as typeof import('../errors');
reject(new SIGINTError('Task aborted on SIGINT signal'));
};
addSIGINTHandler(sigintHandler);
});
try {
return await Promise.race([sigintPromise, task(...theArgs)]);
} finally {
process.removeListener('SIGINT', sigintHandler);
}
}
/** Check if `drive` is mounted, and if so umount it. No-op on Windows. */
export async function safeUmount(drive: string) {
if (!drive) {
return;
}
const { isMounted, umount } = await import('umount');
const isMountedAsync = promisify(isMounted);
if (await isMountedAsync(drive)) {
const umountAsync = promisify(umount);
await umountAsync(drive);
}
}