2017-12-20 21:46:01 +00:00
|
|
|
/*
|
2019-03-04 15:08:58 +00:00
|
|
|
Copyright 2016-2019 Balena
|
2017-12-20 21:46:01 +00:00
|
|
|
|
|
|
|
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.
|
|
|
|
*/
|
|
|
|
|
2019-03-12 22:07:57 +00:00
|
|
|
import BalenaSdk = require('balena-sdk');
|
2018-11-05 08:18:18 +00:00
|
|
|
import Bluebird = require('bluebird');
|
2017-12-20 21:46:01 +00:00
|
|
|
import chalk from 'chalk';
|
2019-03-12 22:07:57 +00:00
|
|
|
import _ = require('lodash');
|
|
|
|
import os = require('os');
|
2017-12-20 21:46:01 +00:00
|
|
|
import visuals = require('resin-cli-visuals');
|
2019-10-13 23:52:43 +00:00
|
|
|
import * as ShellEscape from 'shell-escape';
|
2017-12-20 21:46:01 +00:00
|
|
|
|
2018-11-05 08:18:18 +00:00
|
|
|
import { InitializeEmitter, OperationState } from 'balena-device-init';
|
2017-12-20 21:46:01 +00:00
|
|
|
|
2018-10-19 14:38:50 +00:00
|
|
|
const balena = BalenaSdk.fromSharedOptions();
|
2017-12-20 21:46:01 +00:00
|
|
|
|
2018-01-09 15:05:24 +00:00
|
|
|
export function getGroupDefaults(group: {
|
2019-03-12 22:07:57 +00:00
|
|
|
options: Array<{ name: string; default?: string }>;
|
2019-10-31 01:46:14 +00:00
|
|
|
}): { [name: string]: string | number | undefined } {
|
2018-06-12 15:43:15 +00:00
|
|
|
return _.chain(group)
|
2017-12-20 21:46:01 +00:00
|
|
|
.get('options')
|
2018-01-09 15:05:24 +00:00
|
|
|
.map(question => [question.name, question.default])
|
2017-12-20 21:46:01 +00:00
|
|
|
.fromPairs()
|
|
|
|
.value();
|
2018-01-04 14:07:55 +00:00
|
|
|
}
|
2017-12-20 21:46:01 +00:00
|
|
|
|
|
|
|
export function stateToString(state: OperationState) {
|
2018-01-04 16:17:43 +00:00
|
|
|
const percentage = _.padStart(`${state.percentage}`, 3, '0');
|
2018-01-09 15:05:24 +00:00
|
|
|
const result = `${chalk.blue(percentage + '%')} ${chalk.cyan(
|
|
|
|
state.operation.command,
|
|
|
|
)}`;
|
2017-12-20 21:46:01 +00:00
|
|
|
|
|
|
|
switch (state.operation.command) {
|
|
|
|
case 'copy':
|
2019-06-10 10:07:51 +00:00
|
|
|
return `${result} ${state.operation.from.path} -> ${
|
|
|
|
state.operation.to.path
|
|
|
|
}`;
|
2017-12-20 21:46:01 +00:00
|
|
|
case 'replace':
|
2019-06-10 10:07:51 +00:00
|
|
|
return `${result} ${state.operation.file.path}, ${
|
|
|
|
state.operation.copy
|
|
|
|
} -> ${state.operation.replace}`;
|
2017-12-20 21:46:01 +00:00
|
|
|
case 'run-script':
|
|
|
|
return `${result} ${state.operation.script}`;
|
|
|
|
default:
|
|
|
|
throw new Error(`Unsupported operation: ${state.operation.command}`);
|
|
|
|
}
|
2018-01-04 14:07:55 +00:00
|
|
|
}
|
2017-12-20 21:46:01 +00:00
|
|
|
|
2019-10-13 23:52:43 +00:00
|
|
|
/**
|
|
|
|
* 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(
|
2018-05-22 15:12:51 +00:00
|
|
|
command: string[],
|
2019-10-13 23:52:43 +00:00
|
|
|
{
|
|
|
|
stderr,
|
|
|
|
msg,
|
|
|
|
isCLIcmd,
|
|
|
|
}: { stderr?: NodeJS.WritableStream; msg?: string; isCLIcmd?: boolean } = {},
|
2018-05-22 15:12:51 +00:00
|
|
|
) {
|
2019-10-13 23:52:43 +00:00
|
|
|
const { executeWithPrivileges } = await import('./sudo');
|
2018-05-22 15:12:51 +00:00
|
|
|
|
2017-12-20 21:46:01 +00:00
|
|
|
if (os.platform() !== 'win32') {
|
2018-05-22 15:12:51 +00:00
|
|
|
console.log(
|
2019-10-13 23:52:43 +00:00
|
|
|
msg ||
|
|
|
|
'Admin privileges required: you may be asked for your computer password to continue.',
|
2018-05-22 15:12:51 +00:00
|
|
|
);
|
2017-12-20 21:46:01 +00:00
|
|
|
}
|
2019-10-13 23:52:43 +00:00
|
|
|
if (isCLIcmd == null) {
|
|
|
|
isCLIcmd = true;
|
|
|
|
}
|
|
|
|
await executeWithPrivileges(command, stderr, isCLIcmd);
|
2018-05-22 15:12:51 +00:00
|
|
|
}
|
2017-12-20 21:46:01 +00:00
|
|
|
|
2018-11-05 08:18:18 +00:00
|
|
|
export function runCommand(command: string): Bluebird<void> {
|
2018-05-22 15:12:51 +00:00
|
|
|
const capitano = require('capitano');
|
2018-11-05 08:18:18 +00:00
|
|
|
return Bluebird.fromCallback(resolver => capitano.run(command, resolver));
|
2018-01-04 14:07:55 +00:00
|
|
|
}
|
2017-12-20 21:46:01 +00:00
|
|
|
|
2018-11-05 08:18:18 +00:00
|
|
|
export async function getManifest(
|
2018-01-09 15:05:24 +00:00
|
|
|
image: string,
|
|
|
|
deviceType: string,
|
2018-10-19 14:38:50 +00:00
|
|
|
): Promise<BalenaSdk.DeviceType> {
|
2018-11-05 08:18:18 +00:00
|
|
|
const init = await import('balena-device-init');
|
|
|
|
const manifest = await init.getImageManifest(image);
|
|
|
|
if (manifest != null) {
|
|
|
|
return manifest;
|
|
|
|
}
|
|
|
|
return balena.models.device.getManifestBySlug(deviceType);
|
|
|
|
}
|
|
|
|
|
2018-12-14 10:55:35 +00:00
|
|
|
export const areDeviceTypesCompatible = (
|
|
|
|
deviceTypeA: BalenaSdk.DeviceType,
|
|
|
|
deviceTypeB: BalenaSdk.DeviceType,
|
|
|
|
) =>
|
|
|
|
deviceTypeA.arch === deviceTypeB.arch &&
|
|
|
|
!!deviceTypeA.isDependent === !!deviceTypeB.isDependent;
|
|
|
|
|
2019-10-13 23:52:43 +00:00
|
|
|
export async function osProgressHandler(step: InitializeEmitter) {
|
2017-12-20 21:46:01 +00:00
|
|
|
step.on('stdout', process.stdout.write.bind(process.stdout));
|
|
|
|
step.on('stderr', process.stderr.write.bind(process.stderr));
|
|
|
|
|
|
|
|
step.on('state', function(state) {
|
2018-01-09 15:05:24 +00:00
|
|
|
if (state.operation.command === 'burn') {
|
|
|
|
return;
|
|
|
|
}
|
2018-01-04 16:17:43 +00:00
|
|
|
console.log(exports.stateToString(state));
|
2017-12-20 21:46:01 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
const progressBars = {
|
|
|
|
write: new visuals.Progress('Writing Device OS'),
|
2018-01-04 14:07:55 +00:00
|
|
|
check: new visuals.Progress('Validating Device OS'),
|
2017-12-20 21:46:01 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
step.on('burn', state => progressBars[state.type].update(state));
|
|
|
|
|
2019-10-13 23:52:43 +00:00
|
|
|
await new Promise((resolve, reject) => {
|
|
|
|
step.on('error', reject);
|
|
|
|
step.on('end', resolve);
|
|
|
|
});
|
2018-01-04 14:07:55 +00:00
|
|
|
}
|
2017-12-20 21:46:01 +00:00
|
|
|
|
2018-01-09 15:05:24 +00:00
|
|
|
export function getArchAndDeviceType(
|
|
|
|
applicationName: string,
|
2018-11-05 08:18:18 +00:00
|
|
|
): Bluebird<{ arch: string; device_type: string }> {
|
|
|
|
return Bluebird.join(
|
2017-12-20 21:46:01 +00:00
|
|
|
getApplication(applicationName),
|
2018-10-19 14:38:50 +00:00
|
|
|
balena.models.config.getDeviceTypes(),
|
2018-01-09 15:05:24 +00:00
|
|
|
function(app, deviceTypes) {
|
2019-09-11 13:35:06 +00:00
|
|
|
const config = _.find<BalenaSdk.DeviceType>(deviceTypes, {
|
|
|
|
slug: app.device_type,
|
|
|
|
});
|
2017-12-20 21:46:01 +00:00
|
|
|
|
2018-01-04 16:17:43 +00:00
|
|
|
if (!config) {
|
2017-12-20 21:46:01 +00:00
|
|
|
throw new Error('Could not read application information!');
|
|
|
|
}
|
|
|
|
|
|
|
|
return { device_type: app.device_type, arch: config.arch };
|
2018-01-04 14:07:55 +00:00
|
|
|
},
|
2017-12-20 21:46:01 +00:00
|
|
|
);
|
2018-01-04 14:07:55 +00:00
|
|
|
}
|
2017-12-20 21:46:01 +00:00
|
|
|
|
2017-12-11 22:37:56 +00:00
|
|
|
export function getApplication(applicationName: string) {
|
2017-12-20 21:46:01 +00:00
|
|
|
// Check for an app of the form `user/application`, and send
|
2018-01-04 16:17:43 +00:00
|
|
|
// that off to a special handler (before importing any modules)
|
2018-05-22 15:12:51 +00:00
|
|
|
const match = applicationName.split('/');
|
2018-01-04 16:17:43 +00:00
|
|
|
|
2019-09-11 13:35:06 +00:00
|
|
|
const extraOptions: BalenaSdk.PineOptionsFor<BalenaSdk.Application> = {
|
2018-03-19 18:58:05 +00:00
|
|
|
$expand: {
|
|
|
|
application_type: {
|
2018-03-20 11:06:44 +00:00
|
|
|
$select: ['name', 'slug', 'supports_multicontainer', 'is_legacy'],
|
2018-03-19 18:58:05 +00:00
|
|
|
},
|
|
|
|
},
|
|
|
|
};
|
|
|
|
|
2018-05-22 15:12:51 +00:00
|
|
|
if (match.length > 1) {
|
2018-10-19 14:38:50 +00:00
|
|
|
return balena.models.application.getAppByOwner(
|
2018-03-20 11:06:44 +00:00
|
|
|
match[1],
|
2018-05-22 15:12:51 +00:00
|
|
|
match[0],
|
2018-03-20 11:06:44 +00:00
|
|
|
extraOptions,
|
|
|
|
);
|
2017-12-20 21:46:01 +00:00
|
|
|
}
|
|
|
|
|
2018-10-19 14:38:50 +00:00
|
|
|
return balena.models.application.get(applicationName, extraOptions);
|
2018-01-04 14:07:55 +00:00
|
|
|
}
|
2017-12-20 21:46:01 +00:00
|
|
|
|
|
|
|
// A function to reliably execute a command
|
|
|
|
// in all supported operating systems, including
|
|
|
|
// different Windows environments like `cmd.exe`
|
|
|
|
// and `Cygwin`.
|
|
|
|
export function getSubShellCommand(command: string) {
|
|
|
|
if (os.platform() === 'win32') {
|
|
|
|
return {
|
|
|
|
program: 'cmd.exe',
|
2018-01-09 15:05:24 +00:00
|
|
|
args: ['/s', '/c', command],
|
2017-12-20 21:46:01 +00:00
|
|
|
};
|
|
|
|
} else {
|
|
|
|
return {
|
|
|
|
program: '/bin/sh',
|
2018-01-09 15:05:24 +00:00
|
|
|
args: ['-c', command],
|
2017-12-20 21:46:01 +00:00
|
|
|
};
|
|
|
|
}
|
2018-01-04 14:07:55 +00:00
|
|
|
}
|
2019-03-04 15:08:58 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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 delayMs before the first retry, multiplying the wait
|
|
|
|
* by backoffScaler for each further attempt.
|
|
|
|
* @param func: The function to call and, if needed, retry calling
|
|
|
|
* @param times: How many times to retry calling func()
|
|
|
|
* @param label: Label to include in the retry log message
|
|
|
|
* @param delayMs: How long to wait before the first retry
|
|
|
|
* @param backoffScaler: Multiplier to previous wait time
|
|
|
|
* @param count: Used "internally" for the recursive calls
|
|
|
|
*/
|
|
|
|
export function retry<T>(
|
|
|
|
func: () => T,
|
|
|
|
times: number,
|
|
|
|
label: string,
|
|
|
|
delayMs = 1000,
|
|
|
|
backoffScaler = 2,
|
|
|
|
count = 0,
|
|
|
|
): Bluebird<T> {
|
|
|
|
let promise = Bluebird.try(func);
|
|
|
|
if (count < times) {
|
|
|
|
promise = promise.catch((err: Error) => {
|
|
|
|
const delay = backoffScaler ** count * delayMs;
|
|
|
|
console.log(
|
|
|
|
`Retrying "${label}" after ${(delay / 1000).toFixed(2)}s (${count +
|
|
|
|
1} of ${times}) due to: ${err}`,
|
|
|
|
);
|
|
|
|
return Bluebird.delay(delay).then(() =>
|
|
|
|
retry(func, times, label, delayMs, backoffScaler, count + 1),
|
|
|
|
);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
return promise;
|
|
|
|
}
|
2019-04-02 11:26:21 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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
|
2019-04-29 21:31:49 +00:00
|
|
|
* 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
|
2019-04-02 11:26:21 +00:00
|
|
|
* not-found elements in sorted order).
|
|
|
|
*
|
2019-04-29 21:31:49 +00:00
|
|
|
* 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.
|
2019-04-02 11:26:21 +00:00
|
|
|
*
|
|
|
|
* @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;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
}
|
2019-10-13 23:52:43 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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[] {
|
|
|
|
let isWindowsCmdExeShell: boolean;
|
|
|
|
if (detectShell) {
|
|
|
|
isWindowsCmdExeShell =
|
|
|
|
// neither bash nor sh (e.g. not MSYS, MSYS2, WSL)
|
|
|
|
process.env.SHELL == null &&
|
|
|
|
// Windows cmd.exe or PowerShell
|
|
|
|
process.env.ComSpec != null &&
|
|
|
|
process.env.ComSpec.endsWith('cmd.exe');
|
|
|
|
} else {
|
|
|
|
isWindowsCmdExeShell = process.platform === 'win32';
|
|
|
|
}
|
|
|
|
if (isWindowsCmdExeShell) {
|
|
|
|
return args.map(v => windowsCmdExeEscapeArg(v));
|
|
|
|
} else {
|
|
|
|
const shellEscapeFunc: typeof ShellEscape = 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, '""')}"`;
|
|
|
|
}
|
2019-11-27 10:34:24 +00:00
|
|
|
|
|
|
|
/*
|
|
|
|
* Workaround a window system bug which causes multiple rapid DNS lookups
|
|
|
|
* to fail for mDNS.
|
|
|
|
*
|
|
|
|
* It introduces a simple pause, and should be used between operations that
|
|
|
|
* trigger mDNS resolutions.
|
|
|
|
*
|
|
|
|
* Windows bug: https://support.microsoft.com/en-gb/help/4057932/getaddrinfo-failed-with-wsahost-not-found-11001-error
|
|
|
|
*/
|
|
|
|
export async function workaroundWindowsDnsIssue(ipOrHostname: string) {
|
|
|
|
// 300ms seemed to be the smallest delay that worked reliably but may
|
|
|
|
// vary between systems.
|
|
|
|
const delay = 500;
|
|
|
|
if (process.platform === 'win32' && ipOrHostname.includes('.local')) {
|
|
|
|
await new Promise(r => setTimeout(r, delay));
|
|
|
|
}
|
|
|
|
}
|