Merge pull request #2312 from balena-io/remove-exitWithExpectedError

build, deploy: Extend CTRL-C coverage on Windows (PowerShell, cmd.exe)
This commit is contained in:
bulldozer-balena[bot] 2021-08-27 00:21:24 +00:00 committed by GitHub
commit 8db36ccec9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 182 additions and 216 deletions

View File

@ -101,7 +101,7 @@ async function printMarkdown() {
console.log(await renderMarkdown());
} catch (error) {
console.error(error);
process.exit(1);
process.exitCode = 1;
}
}

View File

@ -41,17 +41,25 @@ function checkNpmVersion() {
// the reason is that it would unnecessarily prevent end users from
// using npm v6.4.1 that ships with Node 8. (It is OK for the
// shrinkwrap file to get damaged if it is not going to be reused.)
console.error(`\
-------------------------------------------------------------------------------
throw new Error(`\
-----------------------------------------------------------------------------
Error: npm version '${npmVersion}' detected. Please upgrade to npm v${requiredVersion} or later
because of a bug that causes the 'npm-shrinkwrap.json' file to be damaged.
At this point, however, your 'npm-shrinkwrap.json' file has already been
damaged. Please revert it to the master branch state with a command such as:
"git checkout master -- npm-shrinkwrap.json"
Then re-run "npm install" using npm version ${requiredVersion} or later.
-------------------------------------------------------------------------------`);
process.exit(1);
-----------------------------------------------------------------------------`);
}
}
checkNpmVersion();
function main() {
try {
checkNpmVersion();
} catch (e) {
console.error(e.message || e);
process.exitCode = 1;
}
}
main();

View File

@ -54,9 +54,7 @@ export async function release() {
try {
await createGitHubRelease();
} catch (err) {
console.error('Release failed');
console.error(err);
process.exit(1);
throw new Error(`Error creating GitHub release:\n${err}`);
}
}

View File

@ -35,11 +35,6 @@ process.env.DEBUG = ['0', 'no', 'false', '', undefined].includes(
? ''
: '1';
function exitWithError(error: Error | string): never {
console.error(`Error: ${error}`);
process.exit(1);
}
/**
* Trivial command-line parser. Check whether the command-line argument is one
* of the following strings, then call the appropriate functions:
@ -49,12 +44,12 @@ function exitWithError(error: Error | string): never {
*
* @param args Arguments to parse (default is process.argv.slice(2))
*/
export async function run(args?: string[]) {
async function parse(args?: string[]) {
args = args || process.argv.slice(2);
console.log(`automation/run.ts process.argv=[${process.argv}]\n`);
console.log(`automation/run.ts args=[${args}]`);
console.error(`[debug] automation/run.ts process.argv=[${process.argv}]`);
console.error(`[debug] automation/run.ts args=[${args}]`);
if (_.isEmpty(args)) {
return exitWithError('missing command-line arguments');
throw new Error('missing command-line arguments');
}
const commands: { [cmd: string]: () => void | Promise<void> } = {
'build:installer': buildOclifInstaller,
@ -66,7 +61,7 @@ export async function run(args?: string[]) {
};
for (const arg of args) {
if (!commands.hasOwnProperty(arg)) {
return exitWithError(`command unknown: ${arg}`);
throw new Error(`command unknown: ${arg}`);
}
}
@ -90,9 +85,22 @@ export async function run(args?: string[]) {
const cmdFunc = commands[arg];
await cmdFunc();
} catch (err) {
return exitWithError(`"${arg}": ${err}`);
if (typeof err === 'object') {
err.message = `"${arg}": ${err.message}`;
}
throw err;
}
}
}
/** See jsdoc for parse() function above */
export async function run(args?: string[]) {
try {
await parse(args);
} catch (e) {
console.error(e.message ? `Error: ${e.message}` : e);
process.exitCode = 1;
}
}
run();

View File

@ -11,8 +11,7 @@ const validateChangeType = (maybeChangeType: string = 'minor') => {
case 'major':
return maybeChangeType;
default:
console.error(`Invalid change type: '${maybeChangeType}'`);
return process.exit(1);
throw new Error(`Invalid change type: '${maybeChangeType}'`);
}
};
@ -65,24 +64,17 @@ const getUpstreams = async () => {
return upstream;
};
const printUsage = (upstreams: Upstream[], upstreamName: string) => {
console.error(
`
const getUsage = (upstreams: Upstream[], upstreamName: string) => `
Usage: npm run update ${upstreamName} $version [$changeType=minor]
Upstream names: ${upstreams.map(({ repo }) => repo).join(', ')}
`,
);
return process.exit(1);
};
`;
// TODO: Drop the wrapper function once we move to TS 3.8,
// which will support top level await.
async function main() {
async function $main() {
const upstreams = await getUpstreams();
if (process.argv.length < 3) {
return printUsage(upstreams, '$upstreamName');
throw new Error(getUsage(upstreams, '$upstreamName'));
}
const upstreamName = process.argv[2];
@ -90,16 +82,15 @@ async function main() {
const upstream = upstreams.find((v) => v.repo === upstreamName);
if (!upstream) {
console.error(
throw new Error(
`Invalid upstream name '${upstreamName}', valid options: ${upstreams
.map(({ repo }) => repo)
.join(', ')}`,
);
return process.exit(1);
}
if (process.argv.length < 4) {
printUsage(upstreams, upstreamName);
throw new Error(getUsage(upstreams, upstreamName));
}
const packageName = upstream.module || upstream.repo;
@ -108,8 +99,7 @@ async function main() {
await run(`npm install ${packageName}@${process.argv[3]}`);
const newVersion = await getVersion(packageName);
if (newVersion === oldVersion) {
console.error(`Already on version '${newVersion}'`);
return process.exit(1);
throw new Error(`Already on version '${newVersion}'`);
}
console.log(`Updated ${upstreamName} from ${oldVersion} to ${newVersion}`);
@ -137,4 +127,13 @@ async function main() {
);
}
async function main() {
try {
await $main();
} catch (e) {
console.error(e);
process.exitCode = 1;
}
}
main();

View File

@ -91,8 +91,6 @@ export default class EnvRmCmd extends Command {
await confirm(
opt.yes || false,
'Are you sure you want to delete the environment variable?',
undefined,
true,
);
const balena = getBalenaSdk();

View File

@ -20,12 +20,7 @@ import type { BlockDevice } from 'etcher-sdk/build/source-destination';
import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import {
getChalk,
getCliForm,
getVisuals,
stripIndent,
} from '../../utils/lazy';
import { getChalk, getVisuals, stripIndent } from '../../utils/lazy';
interface FlagsDef {
yes: boolean;
@ -93,24 +88,15 @@ export default class LocalFlashCmd extends Command {
}
}
const { sourceDestination, multiWrite } = await import('etcher-sdk');
const drive = await this.getDrive(options);
const yes =
options.yes ||
(await getCliForm().ask({
message: 'This will erase the selected drive. Are you sure?',
type: 'confirm',
name: 'yes',
default: false,
}));
if (!yes) {
console.log(getChalk().red.bold('Aborted image flash'));
process.exit(0);
}
const { confirm } = await import('../../utils/patterns');
await confirm(
options.yes,
'This will erase the selected drive. Are you sure?',
);
const { sourceDestination, multiWrite } = await import('etcher-sdk');
const file = new sourceDestination.File({
path: params.image,
});

View File

@ -92,7 +92,6 @@ export default class OsInitializeCmd extends Command {
options.yes,
`This will erase ${answers.drive}. Are you sure?`,
`Going to erase ${answers.drive}.`,
true,
);
const { safeUmount } = await import('../../utils/umount');
await safeUmount(answers.drive);

View File

@ -286,24 +286,3 @@ export const printErrorMessage = function (message: string) {
export const printExpectedErrorMessage = function (message: string) {
console.error(`${message}\n`);
};
/**
* Print a friendly error message and exit the CLI with an error code, BYPASSING
* error reporting through Sentry.io's platform (raven.Raven.captureException).
* Note that lib/errors.ts provides top-level error handling code to catch any
* otherwise uncaught errors, AND to report them through Sentry.io. But many
* "expected" errors (say, a JSON parsing error in a file provided by the user)
* don't warrant reporting through Sentry.io. For such mundane errors, catch
* them and call this function.
*
* DEPRECATED: Use `throw new ExpectedError(<message>)` instead.
* If a specific process exit code x must be set, use process.exitCode = x
*/
export function exitWithExpectedError(message: string | Error): never {
if (message instanceof Error) {
({ message } = message);
}
printErrorMessage(message);
process.exit(1);
}

View File

@ -447,7 +447,6 @@ var pushProgressRenderer = function (tty, prefix) {
export class BuildProgressUI {
constructor(tty, descriptors) {
this._handleEvent = this._handleEvent.bind(this);
this._handleInterrupt = this._handleInterrupt.bind(this);
this.start = this.start.bind(this);
this.end = this.end.bind(this);
this._display = this._display.bind(this);
@ -499,14 +498,7 @@ export class BuildProgressUI {
this._serviceToDataMap[service] = event;
}
_handleInterrupt() {
this._cancelled = true;
this.end();
return process.exit(130); // 128 + SIGINT
}
start() {
process.on('SIGINT', this._handleInterrupt);
this._tty.hideCursor();
this._services.forEach((service) => {
this.streams[service].write({ status: 'Preparing...' });
@ -520,7 +512,6 @@ export class BuildProgressUI {
return;
}
this._ended = true;
process.removeListener('SIGINT', this._handleInterrupt);
this._runloop?.end();
this._runloop = null;

View File

@ -237,7 +237,7 @@ interface Renderer {
streams: Dictionary<NodeJS.ReadWriteStream>;
}
export async function buildProject(opts: {
export interface BuildProjectOpts {
docker: Dockerode;
logger: Logger;
projectPath: string;
@ -252,82 +252,99 @@ export async function buildProject(opts: {
dockerfilePath?: string;
nogitignore: boolean;
multiDockerignore: boolean;
}): Promise<BuiltImage[]> {
const { logger, projectName } = opts;
logger.logInfo(`Building for ${opts.arch}/${opts.deviceType}`);
}
let buildSummaryByService: Dictionary<string> | undefined;
export async function buildProject(
opts: BuildProjectOpts,
): Promise<BuiltImage[]> {
await checkBuildSecretsRequirements(opts.docker, opts.projectPath);
const compose = await import('resin-compose-parse');
const imageDescriptors = compose.parse(opts.composition);
const imageDescriptorsByServiceName = _.keyBy(
imageDescriptors,
'serviceName',
);
const renderer = await startRenderer({ imageDescriptors, ...opts });
let buildSummaryByService: Dictionary<string> | undefined;
try {
await checkBuildSecretsRequirements(opts.docker, opts.projectPath);
const needsQemu = await installQemuIfNeeded({ ...opts, imageDescriptors });
const tarStream = await tarDirectory(opts.projectPath, opts);
const tasks: BuildTaskPlus[] = await makeBuildTasks(
opts.composition,
tarStream,
const { awaitInterruptibleTask } = await import('./helpers');
const [images, summaryMsgByService] = await awaitInterruptibleTask(
$buildProject,
imageDescriptors,
renderer,
opts,
logger,
projectName,
);
setTaskAttributes({ tasks, imageDescriptorsByServiceName, ...opts });
const transposeOptArray: Array<TransposeOptions | undefined> =
await Promise.all(
tasks.map((task) => {
// Setup emulation if needed
if (needsQemu && !task.external) {
return qemuTransposeBuildStream({ task, ...opts });
}
}),
);
await Promise.all(
// transposeOptions may be undefined. That's OK.
transposeOptArray.map((transposeOptions, index) =>
setTaskProgressHooks({
task: tasks[index],
renderer,
transposeOptions,
...opts,
}),
),
);
logger.logDebug('Prepared tasks; building...');
const { BALENA_ENGINE_TMP_PATH } = await import('../config');
const builder = await import('resin-multibuild');
const builtImages = await builder.performBuilds(
tasks,
opts.docker,
BALENA_ENGINE_TMP_PATH,
);
const [images, summaryMsgByService] = await inspectBuiltImages({
builtImages,
imageDescriptorsByServiceName,
tasks,
...opts,
});
buildSummaryByService = summaryMsgByService;
return images;
} finally {
renderer.end(buildSummaryByService);
}
}
async function $buildProject(
imageDescriptors: ImageDescriptor[],
renderer: Renderer,
opts: BuildProjectOpts,
): Promise<[BuiltImage[], Dictionary<string>]> {
const { logger, projectName } = opts;
logger.logInfo(`Building for ${opts.arch}/${opts.deviceType}`);
const needsQemu = await installQemuIfNeeded({ ...opts, imageDescriptors });
const tarStream = await tarDirectory(opts.projectPath, opts);
const tasks: BuildTaskPlus[] = await makeBuildTasks(
opts.composition,
tarStream,
opts,
logger,
projectName,
);
const imageDescriptorsByServiceName = _.keyBy(
imageDescriptors,
'serviceName',
);
setTaskAttributes({ tasks, imageDescriptorsByServiceName, ...opts });
const transposeOptArray: Array<TransposeOptions | undefined> =
await Promise.all(
tasks.map((task) => {
// Setup emulation if needed
if (needsQemu && !task.external) {
return qemuTransposeBuildStream({ task, ...opts });
}
}),
);
await Promise.all(
// transposeOptions may be undefined. That's OK.
transposeOptArray.map((transposeOptions, index) =>
setTaskProgressHooks({
task: tasks[index],
renderer,
transposeOptions,
...opts,
}),
),
);
logger.logDebug('Prepared tasks; building...');
const { BALENA_ENGINE_TMP_PATH } = await import('../config');
const builder = await import('resin-multibuild');
const builtImages = await builder.performBuilds(
tasks,
opts.docker,
BALENA_ENGINE_TMP_PATH,
);
return await inspectBuiltImages({
builtImages,
imageDescriptorsByServiceName,
tasks,
...opts,
});
}
async function startRenderer({
imageDescriptors,
inlineLogs,
@ -1344,20 +1361,25 @@ export async function deployProject(
logger.logDebug('Tagging images...');
const taggedImages = await tagServiceImages(docker, images, serviceImages);
try {
const token = await getTokenForPreviousRepos(
logger,
appId,
apiEndpoint,
taggedImages,
);
await pushServiceImages(
docker,
logger,
pineClient,
taggedImages,
token,
skipLogUpload,
);
const { awaitInterruptibleTask } = await import('./helpers');
// awaitInterruptibleTask throws SIGINTError on CTRL-C,
// causing the release status to be set to 'failed'
await awaitInterruptibleTask(async () => {
const token = await getTokenForPreviousRepos(
logger,
appId,
apiEndpoint,
taggedImages,
);
await pushServiceImages(
docker,
logger,
pineClient,
taggedImages,
token,
skipLogUpload,
);
});
release.status = 'success';
} catch (err) {
release.status = 'failed';

View File

@ -16,18 +16,12 @@ limitations under the License.
import type * as BalenaSdk from 'balena-sdk';
import _ = require('lodash');
import {
exitWithExpectedError,
instanceOf,
NotLoggedInError,
ExpectedError,
} from '../errors';
import { instanceOf, NotLoggedInError, ExpectedError } from '../errors';
import { getBalenaSdk, getVisuals, stripIndent, getCliForm } from './lazy';
import validation = require('./validation');
import { delay } from './helpers';
import { isV13 } from './version';
import type { Application, Device, Organization } from 'balena-sdk';
import { getApplication } from './sdk';
export function authenticate(options: {}): Promise<void> {
const balena = getBalenaSdk();
@ -135,18 +129,16 @@ export function selectDeviceType() {
/**
* Display interactive confirmation prompt.
* If the user declines, then either an error will be thrown,
* or `exitWithExpectedError` will be called (if exitIfDeclined true).
* Throw ExpectedError if the user declines.
* @param yesOption - automatically confirm if true
* @param message - message to display with prompt
* @param yesMessage - message to display if automatically confirming
* @param exitIfDeclined - exitWithExpectedError when decline if true
*/
export async function confirm(
yesOption: boolean,
message: string,
yesMessage?: string,
exitIfDeclined = false,
defaultValue = false,
) {
if (yesOption) {
if (yesMessage) {
@ -162,16 +154,11 @@ export async function confirm(
const confirmed = await getCliForm().ask<boolean>({
message,
type: 'confirm',
default: false,
default: defaultValue,
});
if (!confirmed) {
const err = new ExpectedError('Aborted');
// TODO remove this deprecated function (exitWithExpectedError)
if (exitIfDeclined) {
exitWithExpectedError(err);
}
throw err;
throw new ExpectedError('Aborted');
}
}
@ -281,11 +268,9 @@ export async function awaitDeviceOsUpdate(
}
if (osUpdateStatus.error) {
console.error(
`Failed to complete Host OS update on device ${deviceName}!`,
throw new ExpectedError(
`Failed to complete Host OS update on device ${deviceName}\n${osUpdateStatus.error}`,
);
exitWithExpectedError(osUpdateStatus.error);
return;
}
if (osUpdateProgress !== null) {
@ -379,6 +364,7 @@ export async function getOnlineTargetDeviceUuid(
let app: Application;
try {
logger.logDebug(`Fetching fleet ${applicationOrDevice}`);
const { getApplication } = await import('./sdk');
app = await getApplication(sdk, applicationOrDevice);
} catch (err) {
const { BalenaApplicationNotFound } = await import('balena-errors');

View File

@ -19,6 +19,7 @@ import type * as BalenaSdk from 'balena-sdk';
import { ExpectedError, printErrorMessage } from '../errors';
import { getVisuals, stripIndent, getCliForm } from './lazy';
import Logger = require('./logger');
import { confirm } from './patterns';
import { exec, execBuffered, getDeviceOsRelease } from './ssh';
const MIN_BALENAOS_VERSION = 'v2.14.0';
@ -211,7 +212,7 @@ async function getOrSelectApplication(
.value();
if (!appName) {
return createOrSelectAppOrExit(sdk, compatibleDeviceTypes, deviceType);
return createOrSelectApp(sdk, compatibleDeviceTypes, deviceType);
}
const options: BalenaSdk.PineOptions<BalenaSdk.Application> = {
@ -239,17 +240,14 @@ async function getOrSelectApplication(
)) as ApplicationWithDeviceType[];
if (applications.length === 0) {
const shouldCreateApp = await getCliForm().ask({
message:
`No fleet found with name "${appName}".\n` +
await confirm(
false,
`No fleet found with name "${appName}".\n` +
'Would you like to create it now?',
type: 'confirm',
default: true,
});
if (shouldCreateApp) {
return createApplication(sdk, deviceType, name);
}
process.exit(1);
undefined,
true,
);
return await createApplication(sdk, deviceType, name);
}
// We've found at least one fleet with the given name.
@ -269,10 +267,7 @@ async function getOrSelectApplication(
return selectAppFromList(applications);
}
// TODO: revisit this function's purpose. It was refactored out of
// `getOrSelectApplication` above in order to satisfy some resin-lint v3
// rules, but it looks like there's a fair amount of duplicate logic.
async function createOrSelectAppOrExit(
async function createOrSelectApp(
sdk: BalenaSdk.BalenaSDK,
compatibleDeviceTypes: string[],
deviceType: string,
@ -291,17 +286,14 @@ async function createOrSelectAppOrExit(
})) as ApplicationWithDeviceType[];
if (applications.length === 0) {
const shouldCreateApp = await getCliForm().ask({
message:
'You have no fleets this device can join.\n' +
await confirm(
false,
'You have no fleets this device can join.\n' +
'Would you like to create one now?',
type: 'confirm',
default: true,
});
if (shouldCreateApp) {
return createApplication(sdk, deviceType);
}
process.exit(1);
undefined,
true,
);
return await createApplication(sdk, deviceType);
}
return selectAppFromList(applications);