Merge pull request #1837 from balena-io/1045-deploy-typescript

Convert selected functions to Typescript and async/await (compose.js)
This commit is contained in:
Paulo Castro 2020-05-22 13:12:12 +01:00 committed by GitHub
commit 6a019af25f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 506 additions and 212 deletions

View File

@ -40,7 +40,10 @@ const deployProject = function(docker, logger, composeOpts, opts) {
const _ = require('lodash');
const doodles = require('resin-doodles');
const sdk = getBalenaSdk();
const { loadProject } = require('../utils/compose_ts');
const {
deployProject: $deployProject,
loadProject,
} = require('../utils/compose_ts');
return Promise.resolve(loadProject(logger, composeOpts, opts.image))
.then(function(project) {
@ -147,7 +150,7 @@ const deployProject = function(docker, logger, composeOpts, opts) {
sdk.auth.getToken(),
sdk.settings.get('apiUrl'),
(userId, auth, apiEndpoint) =>
compose.deployProject(
$deployProject(
docker,
logger,
project.composition,

View File

@ -16,6 +16,7 @@
*/
import * as packageJSON from '../package.json';
import { onceAsync } from './utils/lazy';
class CliSettings {
public readonly settings: any;
@ -46,7 +47,7 @@ class CliSettings {
* Sentry.io setup
* @see https://docs.sentry.io/error-reporting/quickstart/?platform=node
*/
async function setupSentry() {
export const setupSentry = onceAsync(async () => {
const config = await import('./config');
const Sentry = await import('@sentry/node');
Sentry.init({
@ -60,7 +61,8 @@ async function setupSentry() {
platform: process.platform,
});
});
}
return Sentry.getCurrentHub();
});
function checkNodeVersion() {
const validNodeVersions = packageJSON.engines.node;

View File

@ -25,6 +25,27 @@ interface Image {
tag: string;
}
export interface BuiltImage {
logs: string;
name: string;
props: {
dockerfile?: string;
projectType?: string;
size?: number;
};
serviceName: string;
}
export interface TaggedImage {
localImage: import('dockerode').Image;
serviceImage: import('balena-release/build/models').ImageModel;
serviceName: string;
logs: string;
props: BuiltImage.props;
registry: string;
repo: string;
}
export interface ComposeOpts {
dockerfilePath?: string;
inlineLogs?: boolean;
@ -40,6 +61,12 @@ export interface ComposeProject {
descriptors: ImageDescriptor[];
}
export interface Release {
client: import('pinejs-client').ApiClient;
release: Partial<import('balena-release/build/models').ReleaseModel>;
serviceImages: Partial<import('balena-release/build/models').ImageModel>;
}
interface TarDirectoryOptions {
convertEol?: boolean;
preFinalizeCallback?: (pack: Pack) => void | Promise<void>;

View File

@ -19,7 +19,7 @@ import * as Promise from 'bluebird';
import { stripIndent } from 'common-tags';
import * as path from 'path';
import { getBalenaSdk, getChalk } from './lazy';
import { getChalk } from './lazy';
import { IgnoreFileType } from './ignore';
export const appendProjectOptions = opts =>
@ -245,6 +245,11 @@ function originalTarDirectory(dir, param) {
});
}
/**
* @param {string} str
* @param {number} len
* @returns {string}
*/
const truncateString = function(str, len) {
if (str.length < len) {
return str;
@ -485,7 +490,21 @@ export function buildProject(
.finally(renderer.end);
}
const createRelease = function(apiEndpoint, auth, userId, appId, composition) {
/**
* @param {string} apiEndpoint
* @param {string} auth
* @param {number} userId
* @param {number} appId
* @param {import('resin-compose-parse').Composition} composition
* @returns {Promise<import('./compose-types').Release>}
*/
export const createRelease = function(
apiEndpoint,
auth,
userId,
appId,
composition,
) {
const _ = require('lodash');
const crypto = require('crypto');
const releaseMod = require('balena-release');
@ -524,7 +543,14 @@ const createRelease = function(apiEndpoint, auth, userId, appId, composition) {
});
};
const tagServiceImages = (docker, images, serviceImages) =>
/**
*
* @param {import('docker-toolbelt')} docker
* @param {Array<import('./compose-types').BuiltImage>} images
* @param {Partial<import('balena-release/build/models').ImageModel>} serviceImages
* @returns {Promise<Array<import('./compose-types').TaggedImage>>}
*/
export const tagServiceImages = (docker, images, serviceImages) =>
Promise.map(images, function(d) {
const serviceImage = serviceImages[d.serviceName];
const imageName = serviceImage.is_stored_at__image_location;
@ -549,7 +575,14 @@ const tagServiceImages = (docker, images, serviceImages) =>
}));
});
const getPreviousRepos = (sdk, docker, logger, appID) =>
/**
* @param {*} sdk
* @param {import('docker-toolbelt')} docker
* @param {import('./logger')} logger
* @param {number} appID
* @returns {Promise<string[]>}
*/
export const getPreviousRepos = (sdk, docker, logger, appID) =>
sdk.pine
.get({
resource: 'release',
@ -590,7 +623,15 @@ const getPreviousRepos = (sdk, docker, logger, appID) =>
return [];
});
const authorizePush = function(
/**
* @param {*} sdk
* @param {string} tokenAuthEndpoint
* @param {string} registry
* @param {string[]} images
* @param {string[]} previousRepos
* @returns {Promise<string>}
*/
export const authorizePush = function(
sdk,
tokenAuthEndpoint,
registry,
@ -613,10 +654,21 @@ const authorizePush = function(
})
.get('body')
.get('token')
.catchReturn({});
.catchReturn('');
};
const pushAndUpdateServiceImages = function(docker, token, images, afterEach) {
/**
* @param {import('docker-toolbelt')} docker
* @param {string} token
* @param {Array<import('./compose-types').TaggedImage>} images
* @param {(serviceImage: import('balena-release/build/models').ImageModel, props: object) => void} afterEach
*/
export const pushAndUpdateServiceImages = function(
docker,
token,
images,
afterEach,
) {
const { DockerProgress } = require('docker-progress');
const { retry } = require('./helpers');
const tty = require('./tty')(process.stdout);
@ -633,14 +685,18 @@ const pushAndUpdateServiceImages = function(docker, token, images, afterEach) {
return Promise.using(tty.cursorHidden(), () =>
Promise.map(images, ({ serviceImage, localImage, props, logs }, index) =>
Promise.join(
// @ts-ignore
localImage.inspect().get('Size'),
retry(
// @ts-ignore
() => progress.push(localImage.name, reporters[index], opts),
3, // `times` - retry 3 times
// @ts-ignore
localImage.name, // `label` included in retry log messages
2000, // `delayMs` - wait 2 seconds before the 1st retry
1.4, // `backoffScaler` - wait multiplier for each retry
).finally(renderer.end),
/** @type {(size: number, digest: string) => void} */
function(size, digest) {
serviceImage.image_size = size;
serviceImage.content_hash = digest;
@ -666,91 +722,6 @@ const pushAndUpdateServiceImages = function(docker, token, images, afterEach) {
);
};
export function deployProject(
docker,
logger,
composition,
images,
appId,
userId,
auth,
apiEndpoint,
skipLogUpload,
) {
const _ = require('lodash');
const releaseMod = require('balena-release');
const tty = require('./tty')(process.stdout);
const prefix = getChalk().cyan('[Info]') + ' ';
const spinner = createSpinner();
let runloop = runSpinner(tty, spinner, `${prefix}Creating release...`);
return createRelease(apiEndpoint, auth, userId, appId, composition)
.finally(runloop.end)
.then(function({ client, release, serviceImages }) {
logger.logDebug('Tagging images...');
return tagServiceImages(docker, images, serviceImages)
.tap(function(taggedImages) {
logger.logDebug('Authorizing push...');
const sdk = getBalenaSdk();
return getPreviousRepos(sdk, docker, logger, appId)
.then(previousRepos =>
authorizePush(
sdk,
apiEndpoint,
taggedImages[0].registry,
_.map(taggedImages, 'repo'),
previousRepos,
),
)
.then(function(token) {
logger.logInfo('Pushing images to registry...');
return pushAndUpdateServiceImages(
docker,
token,
taggedImages,
function(serviceImage) {
logger.logDebug(
`Saving image ${serviceImage.is_stored_at__image_location}`,
);
if (skipLogUpload) {
delete serviceImage.build_log;
}
return releaseMod.updateImage(
client,
serviceImage.id,
serviceImage,
);
},
);
})
.finally(function() {
logger.logDebug('Untagging images...');
return Promise.map(taggedImages, ({ localImage }) =>
localImage.remove(),
);
});
})
.then(() => {
release.status = 'success';
})
.tapCatch(() => {
release.status = 'failed';
})
.finally(function() {
runloop = runSpinner(tty, spinner, `${prefix}Saving release...`);
release.end_timestamp = new Date();
if (release.id == null) {
return;
}
return releaseMod
.updateRelease(client, release.id, release)
.finally(runloop.end);
})
.return(release);
});
}
// utilities
const renderProgressBar = function(percentage, stepCount) {
@ -874,39 +845,6 @@ var pullProgressAdapter = outStream =>
});
};
var createSpinner = function() {
const chars = '|/-\\';
let index = 0;
return () => chars[index++ % chars.length];
};
var runSpinner = function(tty, spinner, msg) {
const runloop = createRunLoop(function() {
tty.clearLine();
tty.writeLine(`${msg} ${spinner()}`);
return tty.cursorUp();
});
runloop.onEnd = function() {
tty.clearLine();
return tty.writeLine(msg);
};
return runloop;
};
var createRunLoop = function(tick) {
const timerId = setInterval(tick, 1000 / 10);
var runloop = {
onEnd() {
// noop
},
end() {
clearInterval(timerId);
return runloop.onEnd();
},
};
return runloop;
};
class BuildProgressUI {
constructor(tty, descriptors) {
this._handleEvent = this._handleEvent.bind(this);
@ -953,7 +891,7 @@ class BuildProgressUI {
this._startTime = null;
this._ended = false;
this._cancelled = false;
this._spinner = createSpinner();
this._spinner = require('./compose_ts').createSpinner();
this.streams = streams;
}
@ -974,7 +912,7 @@ class BuildProgressUI {
this._services.forEach(service => {
this.streams[service].write({ status: 'Preparing...' });
});
this._runloop = createRunLoop(this._display);
this._runloop = require('./compose_ts').createRunLoop(this._display);
this._startTime = Date.now();
}

View File

@ -27,6 +27,15 @@ import { Readable } from 'stream';
import * as tar from 'tar-stream';
import { ExpectedError } from '../errors';
import { getBalenaSdk, getChalk } from '../utils/lazy';
import {
BuiltImage,
ComposeOpts,
ComposeProject,
Release,
TaggedImage,
TarDirectoryOptions,
} from './compose-types';
import { DeviceInfo } from './device/api';
import Logger = require('./logger');
@ -47,9 +56,9 @@ const compositionFileNames = ['docker-compose.yml', 'docker-compose.yaml'];
*/
export async function loadProject(
logger: Logger,
opts: import('./compose-types').ComposeOpts,
opts: ComposeOpts,
image?: string,
): Promise<import('./compose-types').ComposeProject> {
): Promise<ComposeProject> {
const compose = await import('resin-compose-parse');
const { createProject } = await import('./compose');
let composeName: string;
@ -170,7 +179,7 @@ export async function tarDirectory(
preFinalizeCallback,
convertEol = false,
nogitignore = false,
}: import('./compose-types').TarDirectoryOptions,
}: TarDirectoryOptions,
): Promise<import('stream').Readable> {
(await import('assert')).strict.strictEqual(nogitignore, true);
const { filterFilesWithDockerignore } = await import('./ignore');
@ -567,3 +576,162 @@ export async function validateProjectDirectory(
return result;
}
async function getTokenForPreviousRepos(
docker: import('docker-toolbelt'),
logger: Logger,
appId: number,
apiEndpoint: string,
taggedImages: TaggedImage[],
): Promise<string> {
logger.logDebug('Authorizing push...');
const { authorizePush, getPreviousRepos } = await import('./compose');
const sdk = getBalenaSdk();
const previousRepos = await getPreviousRepos(sdk, docker, logger, appId);
if (!previousRepos || previousRepos.length === 0) {
return '';
}
const token = await authorizePush(
sdk,
apiEndpoint,
taggedImages[0].registry,
_.map(taggedImages, 'repo'),
previousRepos,
);
return token;
}
async function pushServiceImages(
docker: import('docker-toolbelt'),
logger: Logger,
pineClient: import('pinejs-client'),
taggedImages: TaggedImage[],
token: string,
skipLogUpload: boolean,
): Promise<void> {
const { pushAndUpdateServiceImages } = await import('./compose');
const releaseMod = await import('balena-release');
logger.logInfo('Pushing images to registry...');
await pushAndUpdateServiceImages(docker, token, taggedImages, async function(
serviceImage,
) {
logger.logDebug(
`Saving image ${serviceImage.is_stored_at__image_location}`,
);
if (skipLogUpload) {
delete serviceImage.build_log;
}
await releaseMod.updateImage(pineClient, serviceImage.id, serviceImage);
});
}
export async function deployProject(
docker: import('docker-toolbelt'),
logger: Logger,
composition: import('resin-compose-parse').Composition,
images: BuiltImage[],
appId: number,
userId: number,
auth: string,
apiEndpoint: string,
skipLogUpload: boolean,
): Promise<Partial<import('balena-release/build/models').ReleaseModel>> {
const releaseMod = require('balena-release');
const { createRelease, tagServiceImages } = await import('./compose');
const tty = (await import('./tty'))(process.stdout);
const prefix = getChalk().cyan('[Info]') + ' ';
const spinner = createSpinner();
let runloop = runSpinner(tty, spinner, `${prefix}Creating release...`);
let $release: Release;
try {
$release = await createRelease(
apiEndpoint,
auth,
userId,
appId,
composition,
);
} finally {
runloop.end();
}
const { client: pineClient, release, serviceImages } = $release;
try {
logger.logDebug('Tagging images...');
const taggedImages = await tagServiceImages(docker, images, serviceImages);
try {
const token = await getTokenForPreviousRepos(
docker,
logger,
appId,
apiEndpoint,
taggedImages,
);
await pushServiceImages(
docker,
logger,
pineClient,
taggedImages,
token,
skipLogUpload,
);
release.status = 'success';
} catch (err) {
release.status = 'failed';
throw err;
} finally {
logger.logDebug('Untagging images...');
await Bluebird.map(taggedImages, ({ localImage }) => localImage.remove());
}
} finally {
runloop = runSpinner(tty, spinner, `${prefix}Saving release...`);
release.end_timestamp = new Date();
if (release.id != null) {
try {
await releaseMod.updateRelease(pineClient, release.id, release);
} finally {
runloop.end();
}
}
}
return release;
}
export function createSpinner() {
const chars = '|/-\\';
let index = 0;
return () => chars[index++ % chars.length];
}
function runSpinner(
tty: ReturnType<typeof import('./tty')>,
spinner: () => string,
msg: string,
) {
const runloop = createRunLoop(function() {
tty.clearLine();
tty.writeLine(`${msg} ${spinner()}`);
return tty.cursorUp();
});
runloop.onEnd = function() {
tty.clearLine();
return tty.writeLine(msg);
};
return runloop;
}
export function createRunLoop(tick: (...args: any[]) => void) {
const timerId = setInterval(tick, 1000 / 10);
const runloop = {
onEnd() {
// noop
},
end() {
clearInterval(timerId);
return runloop.onEnd();
},
};
return runloop;
}

View File

@ -31,6 +31,16 @@ const once = <T>(fn: () => T) => {
};
};
export const onceAsync = <T>(fn: () => Promise<T>) => {
let cached: T;
return async (): Promise<T> => {
if (!cached) {
cached = await fn();
}
return cached;
};
};
export const getBalenaSdk = once(() =>
(require('balena-sdk') as typeof BalenaSdk).fromSharedOptions(),
);

135
npm-shrinkwrap.json generated
View File

@ -814,33 +814,42 @@
"integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ=="
},
"@sinonjs/commons": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.7.0.tgz",
"integrity": "sha512-qbk9AP+cZUsKdW1GJsBpxPKFmCJ0T8swwzVje3qFd+AkQb74Q/tiuzrdfFg8AD2g5HH/XbE/I8Uc1KYHVYWfhg==",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.0.tgz",
"integrity": "sha512-wEj54PfsZ5jGSwMX68G8ZXFawcSglQSXqCftWX3ec8MDUzQdHgcKvw97awHbY0efQEL5iKUOAmmVtoYgmrSG4Q==",
"dev": true,
"requires": {
"type-detect": "4.0.8"
}
},
"@sinonjs/fake-timers": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz",
"integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==",
"dev": true,
"requires": {
"@sinonjs/commons": "^1.7.0"
}
},
"@sinonjs/formatio": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.2.2.tgz",
"integrity": "sha512-B8SEsgd8gArBLMD6zpRw3juQ2FVSsmdd7qlevyDqzS9WTCtvF55/gAL+h6gue8ZvPYcdiPdvueM/qm//9XzyTQ==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-5.0.1.tgz",
"integrity": "sha512-KaiQ5pBf1MpS09MuA0kp6KBQt2JUOQycqVG1NZXvzeaXe5LGFqAKueIS0bw4w0P9r7KuBSVdUk5QjXsUdu2CxQ==",
"dev": true,
"requires": {
"@sinonjs/commons": "^1",
"@sinonjs/samsam": "^3.1.0"
"@sinonjs/samsam": "^5.0.2"
}
},
"@sinonjs/samsam": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-3.3.3.tgz",
"integrity": "sha512-bKCMKZvWIjYD0BLGnNrxVuw4dkWCYsLqFOUWw8VgKF/+5Y+mE7LfHWPIYoDXowH+3a9LsWDMo0uAP8YDosPvHQ==",
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-5.0.3.tgz",
"integrity": "sha512-QucHkc2uMJ0pFGjJUDP3F9dq5dx8QIaqISl9QgwLOh6P9yv877uONPGXh/OH/0zmM3tW1JjuJltAZV2l7zU+uQ==",
"dev": true,
"requires": {
"@sinonjs/commons": "^1.3.0",
"array-from": "^2.1.1",
"lodash": "^4.17.15"
"@sinonjs/commons": "^1.6.0",
"lodash.get": "^4.4.2",
"type-detect": "^4.0.8"
}
},
"@sinonjs/text-encoding": {
@ -1295,9 +1304,18 @@
"dev": true
},
"@types/sinon": {
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-7.5.2.tgz",
"integrity": "sha512-T+m89VdXj/eidZyejvmoP9jivXgBDdkOSBVQjU9kF349NEx10QdPNGxHeZUaj1IlJ32/ewdyXJjnJxyxJroYwg==",
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-9.0.3.tgz",
"integrity": "sha512-NWVG++603tEDwmz5k0DwFR1hqP3iBmq5GYi6d+0KCQMQsfDEULF1D7xqZ+iXRJHeGwLVhM+Rv73uzIYuIUVlJQ==",
"dev": true,
"requires": {
"@types/sinonjs__fake-timers": "*"
}
},
"@types/sinonjs__fake-timers": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.1.tgz",
"integrity": "sha512-yYezQwGWty8ziyYLdZjwxyMb0CZR49h8JALHGrxjQHWlqGgc8kLdHEgWrgL0uZ29DMvEVBDnHU2Wg36zKSIUtA==",
"dev": true
},
"@types/split": {
@ -1733,12 +1751,6 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz",
"integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ=="
},
"array-from": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz",
"integrity": "sha1-z+nYwmYoudxa7MYqn12PHzUsEZU=",
"dev": true
},
"array-initial": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/array-initial/-/array-initial-1.1.0.tgz",
@ -8314,9 +8326,9 @@
"dev": true
},
"just-extend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.0.2.tgz",
"integrity": "sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.1.0.tgz",
"integrity": "sha512-ApcjaOdVTJ7y4r08xI5wIqpvwS48Q0PBG4DJROcEkH1f8MdAiNFyFxz3xoL0LWAVwjrwPYZdVHHxhRHcx/uGLA==",
"dev": true
},
"jwt-decode": {
@ -8756,12 +8768,6 @@
}
}
},
"lolex": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lolex/-/lolex-4.2.0.tgz",
"integrity": "sha512-gKO5uExCXvSm6zbF562EvM+rd1kQDnB9AZBbiQVzf1ZmdDpxUSvpnAaVOP83N/31mRK8Ml8/VE8DMvsAZQ+7wg==",
"dev": true
},
"loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -13127,15 +13133,15 @@
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="
},
"nise": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/nise/-/nise-1.5.3.tgz",
"integrity": "sha512-Ymbac/94xeIrMf59REBPOv0thr+CJVFMhrlAkW/gjCIE58BGQdCj0x7KRCb3yz+Ga2Rz3E9XXSvUyyxqqhjQAQ==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/nise/-/nise-4.0.3.tgz",
"integrity": "sha512-EGlhjm7/4KvmmE6B/UFsKh7eHykRl9VH+au8dduHLCyWUO/hr7+N+WtTvDUwc9zHuM1IaIJs/0lQ6Ag1jDkQSg==",
"dev": true,
"requires": {
"@sinonjs/formatio": "^3.2.1",
"@sinonjs/commons": "^1.7.0",
"@sinonjs/fake-timers": "^6.0.0",
"@sinonjs/text-encoding": "^0.7.1",
"just-extend": "^4.0.2",
"lolex": "^5.0.1",
"path-to-regexp": "^1.7.0"
},
"dependencies": {
@ -13145,15 +13151,6 @@
"integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=",
"dev": true
},
"lolex": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/lolex/-/lolex-5.1.2.tgz",
"integrity": "sha512-h4hmjAvHTmd+25JSwrtTIuwbKdwg5NzZVRMLn9saij4SZaepCrTCxPr35H/3bjwfMJtN+t3CX8672UIkglz28A==",
"dev": true,
"requires": {
"@sinonjs/commons": "^1.7.0"
}
},
"path-to-regexp": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
@ -13166,15 +13163,14 @@
}
},
"nock": {
"version": "11.9.1",
"resolved": "https://registry.npmjs.org/nock/-/nock-11.9.1.tgz",
"integrity": "sha512-U5wPctaY4/ar2JJ5Jg4wJxlbBfayxgKbiAeGh+a1kk6Pwnc2ZEuKviLyDSG6t0uXl56q7AALIxoM6FJrBSsVXA==",
"version": "12.0.3",
"resolved": "https://registry.npmjs.org/nock/-/nock-12.0.3.tgz",
"integrity": "sha512-QNb/j8kbFnKCiyqi9C5DD0jH/FubFGj5rt9NQFONXwQm3IPB0CULECg/eS3AU1KgZb/6SwUa4/DTRKhVxkGABw==",
"dev": true,
"requires": {
"debug": "^4.1.0",
"json-stringify-safe": "^5.0.1",
"lodash": "^4.17.13",
"mkdirp": "^0.5.0",
"propagate": "^2.0.0"
}
},
@ -16274,25 +16270,40 @@
"dev": true
},
"sinon": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/sinon/-/sinon-7.5.0.tgz",
"integrity": "sha512-AoD0oJWerp0/rY9czP/D6hDTTUYGpObhZjMpd7Cl/A6+j0xBE+ayL/ldfggkBXUs0IkvIiM1ljM8+WkOc5k78Q==",
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/sinon/-/sinon-9.0.2.tgz",
"integrity": "sha512-0uF8Q/QHkizNUmbK3LRFqx5cpTttEVXudywY9Uwzy8bTfZUhljZ7ARzSxnRHWYWtVTeh4Cw+tTb3iU21FQVO9A==",
"dev": true,
"requires": {
"@sinonjs/commons": "^1.4.0",
"@sinonjs/formatio": "^3.2.1",
"@sinonjs/samsam": "^3.3.3",
"diff": "^3.5.0",
"lolex": "^4.2.0",
"nise": "^1.5.2",
"supports-color": "^5.5.0"
"@sinonjs/commons": "^1.7.2",
"@sinonjs/fake-timers": "^6.0.1",
"@sinonjs/formatio": "^5.0.1",
"@sinonjs/samsam": "^5.0.3",
"diff": "^4.0.2",
"nise": "^4.0.1",
"supports-color": "^7.1.0"
},
"dependencies": {
"diff": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz",
"integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
"supports-color": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
"integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==",
"dev": true,
"requires": {
"has-flag": "^4.0.0"
}
}
}
},

View File

@ -135,7 +135,7 @@
"@types/rewire": "^2.5.28",
"@types/rimraf": "^2.0.4",
"@types/shell-escape": "^0.2.0",
"@types/sinon": "^7.5.2",
"@types/sinon": "^9.0.3",
"@types/split": "^1.0.0",
"@types/stream-to-promise": "2.2.0",
"@types/tar-stream": "^2.1.0",
@ -154,13 +154,13 @@
"intercept-stdout": "^0.1.2",
"mocha": "^6.2.3",
"mock-require": "^3.0.3",
"nock": "^11.9.1",
"nock": "^12.0.3",
"parse-link-header": "~1.0.1",
"pkg": "^4.4.2",
"publish-release": "^1.6.1",
"rewire": "^4.0.1",
"simple-git": "^1.131.0",
"sinon": "^7.5.0",
"sinon": "^9.0.2",
"ts-node": "^8.10.1",
"typescript": "^3.9.2"
},

View File

@ -52,7 +52,6 @@ export class BalenaAPIMock extends NockMock {
public expectGetAuth(opts: ScopeOpts = {}) {
this.optGet(/^\/auth\/v1\//, opts).reply(200, {
// "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IlJZVFk6TlE3WDpKSDVCOlFFWFk6RkU2TjpLTlVVOklWNTI6TFFRQTo3UjRWOjJVUFI6Qk9ISjpDNklPIn0.eyJqdGkiOiI3ZTNlN2RmMS1iYjljLTQxZTMtOTlkMi00NjVlMjE4YzFmOWQiLCJuYmYiOjE1NzkxOTQ1MjgsImFjY2VzcyI6W3sibmFtZSI6InYyL2MwODljNDIxZmIyMzM2ZDA0NzUxNjZmYmYzZDBmOWZhIiwidHlwZSI6InJlcG9zaXRvcnkiLCJhY3Rpb25zIjpbInB1bGwiLCJwdXNoIl19LHsibmFtZSI6InYyLzljMDBjOTQxMzk0MmNkMTVjZmM5MTg5YzVkYWMzNTlkIiwidHlwZSI6InJlcG9zaXRvcnkiLCJhY3Rpb25zIjpbInB1bGwiLCJwdXNoIl19XSwiaWF0IjoxNTc5MTk0NTM4LCJleHAiOjE1NzkyMDg5MzgsImF1ZCI6InJlZ2lzdHJ5Mi5iYWxlbmEtY2xvdWQuY29tIiwiaXNzIjoiYXBpLmJhbGVuYS1jbG91ZC5jb20iLCJzdWIiOiJnaF9wYXVsb19jYXN0cm8ifQ.bRw5_lg-nT-c1V4RxIJjujfPuVewZTs0BRNENEw2-sk_6zepLs-sLl9DOSEHYBdi87EtyCiUB3Wqee6fvz2HyQ"
token: 'test',
});
}
@ -65,8 +64,17 @@ export class BalenaAPIMock extends NockMock {
);
}
public expectPatchRelease(opts: ScopeOpts = {}) {
this.optPatch(/^\/v5\/release($|[(?])/, opts).reply(200, 'OK');
public expectPatchRelease({
replyBody = 'OK',
statusCode = 200,
inspectRequest = this.inspectNoOp,
optional = false,
persist = false,
}) {
this.optPatch(/^\/v5\/release($|[(?])/, { optional, persist }).reply(
statusCode,
this.getInspectedReplyBodyFunction(inspectRequest, replyBody),
);
}
public expectPostRelease(opts: ScopeOpts = {}) {
@ -77,8 +85,17 @@ export class BalenaAPIMock extends NockMock {
);
}
public expectPatchImage(opts: ScopeOpts = {}) {
this.optPatch(/^\/v5\/image($|[(?])/, opts).reply(200, 'OK');
public expectPatchImage({
replyBody = 'OK',
statusCode = 200,
inspectRequest = this.inspectNoOp,
optional = false,
persist = false,
}) {
this.optPatch(/^\/v5\/image($|[(?])/, { optional, persist }).reply(
statusCode,
this.getInspectedReplyBodyFunction(inspectRequest, replyBody),
);
}
public expectPostImage(opts: ScopeOpts = {}) {

View File

@ -21,11 +21,12 @@ require('../config-tests'); // required for side effects
import { expect } from 'chai';
import { fs } from 'mz';
import * as path from 'path';
import * as sinon from 'sinon';
import { BalenaAPIMock } from '../balena-api-mock';
import { testDockerBuildStream } from '../docker-build';
import { DockerMock, dockerResponsePath } from '../docker-mock';
import { cleanOutput, runCommand } from '../helpers';
import { cleanOutput, runCommand, switchSentry } from '../helpers';
import { ExpectedTarStreamFiles } from '../projects';
const repoPath = path.normalize(path.join(__dirname, '..', '..'));
@ -55,8 +56,20 @@ const commonQueryParams = [
describe('balena deploy', function() {
let api: BalenaAPIMock;
let docker: DockerMock;
let sentryStatus: boolean | undefined;
const isWindows = process.platform === 'win32';
this.beforeAll(async () => {
sentryStatus = await switchSentry(false);
sinon.stub(process, 'exit');
});
this.afterAll(async () => {
await switchSentry(sentryStatus);
// @ts-ignore
process.exit.restore();
});
this.beforeEach(() => {
api = new BalenaAPIMock();
docker = new DockerMock();
@ -64,7 +77,6 @@ describe('balena deploy', function() {
api.expectGetMixpanel({ optional: true });
api.expectGetDeviceTypes();
api.expectGetApplication();
api.expectPatchRelease();
api.expectPostRelease();
api.expectGetRelease();
api.expectGetUser();
@ -74,7 +86,6 @@ describe('balena deploy', function() {
api.expectPostImage();
api.expectPostImageIsPartOfRelease();
api.expectPostImageLabel();
api.expectPatchImage();
docker.expectGetPing();
docker.expectGetInfo({});
@ -119,6 +130,9 @@ describe('balena deploy', function() {
);
}
api.expectPatchImage({});
api.expectPatchRelease({});
await testDockerBuildStream({
commandLine: `deploy testApp --build --source ${projectPath} -G`,
dockerMock: docker,
@ -131,6 +145,64 @@ describe('balena deploy', function() {
services: ['main'],
});
});
it('should update a release with status="failed" on error (single container)', async () => {
const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic');
const expectedFiles: ExpectedTarStreamFiles = {
'src/start.sh': { fileSize: 89, type: 'file' },
'src/windows-crlf.sh': { fileSize: 70, type: 'file' },
Dockerfile: { fileSize: 88, type: 'file' },
'Dockerfile-alt': { fileSize: 30, type: 'file' },
};
const responseFilename = 'build-POST.json';
const responseBody = await fs.readFile(
path.join(dockerResponsePath, responseFilename),
'utf8',
);
const expectedResponseLines = ['[Error] Deploy failed'];
const errMsg = 'Patch Image Error';
const expectedErrorLines = [errMsg];
// Mock this patch HTTP request to return status code 500, in which case
// the release status should be saved as "failed" rather than "success"
api.expectPatchImage({
replyBody: errMsg,
statusCode: 500,
inspectRequest: (_uri, requestBody) => {
const imageBody = requestBody as Partial<
import('balena-release/build/models').ImageModel
>;
expect(imageBody.status).to.equal('success');
},
});
// Check that the CLI patches the release with status="failed"
api.expectPatchRelease({
inspectRequest: (_uri, requestBody) => {
const releaseBody = requestBody as Partial<
import('balena-release/build/models').ReleaseModel
>;
expect(releaseBody.status).to.equal('failed');
},
});
await testDockerBuildStream({
commandLine: `deploy testApp --build --source ${projectPath} -G`,
dockerMock: docker,
expectedFilesByService: { main: expectedFiles },
expectedQueryParamsByService: { main: commonQueryParams },
expectedErrorLines,
expectedResponseLines,
projectPath,
responseBody,
responseCode: 200,
services: ['main'],
});
// The SDK should produce an "unexpected" BalenaRequestError, which
// causes the CLI to call process.exit() with process.exitCode = 1
// @ts-ignore
sinon.assert.calledWith(process.exit);
expect(process.exitCode).to.equal(1);
});
});
describe('balena deploy: project validation', function() {

View File

@ -138,12 +138,14 @@ export async function testDockerBuildStream(o: {
dockerMock: DockerMock;
expectedFilesByService: ExpectedTarStreamFilesByService;
expectedQueryParamsByService: { [service: string]: string[][] };
expectedErrorLines?: string[];
expectedResponseLines: string[];
projectPath: string;
responseCode: number;
responseBody: string;
services: string[]; // e.g. ['main'] or ['service1', 'service2']
}) {
const expectedErrorLines = fillTemplateArray(o.expectedErrorLines || [], o);
const expectedResponseLines = fillTemplateArray(o.expectedResponseLines, o);
for (const service of o.services) {
@ -174,10 +176,19 @@ export async function testDockerBuildStream(o: {
const { out, err } = await runCommand(o.commandLine);
expect(err).to.be.empty;
expect(
cleanOutput(out).map(line => line.replace(/\s{2,}/g, ' ')),
).to.include.members(expectedResponseLines);
const cleanLines = (lines: string[]) =>
cleanOutput(lines).map(line => line.replace(/\s{2,}/g, ' '));
if (expectedErrorLines.length) {
expect(cleanLines(err)).to.include.members(expectedErrorLines);
} else {
expect(err).to.be.empty;
}
if (expectedResponseLines.length) {
expect(cleanLines(out)).to.include.members(expectedResponseLines);
} else {
expect(out).to.be.empty;
}
}
/**

View File

@ -24,6 +24,7 @@ import * as nock from 'nock';
import * as path from 'path';
import * as balenaCLI from '../build/app';
import { setupSentry } from '../build/app-common';
export const runCommand = async (cmd: string) => {
const preArgs = [process.argv[0], path.join(process.cwd(), 'bin', 'balena')];
@ -147,3 +148,14 @@ export function fillTemplateArray(
: fillTemplate(i, templateVars),
);
}
export async function switchSentry(
enabled: boolean | undefined,
): Promise<boolean | undefined> {
const sentryOpts = (await setupSentry()).getClient()?.getOptions();
if (sentryOpts) {
const sentryStatus = sentryOpts.enabled;
sentryOpts.enabled = enabled;
return sentryStatus;
}
}

View File

@ -83,6 +83,29 @@ export class NockMock {
return optional ? post.optionally() : post;
}
protected inspectNoOp(_uri: string, _requestBody: nock.Body): void {
return undefined;
}
protected getInspectedReplyBodyFunction(
inspectRequest: (uri: string, requestBody: nock.Body) => void,
replyBody: nock.ReplyBody,
) {
return function(
this: nock.ReplyFnContext,
uri: string,
requestBody: nock.Body,
cb: (err: NodeJS.ErrnoException | null, result: nock.ReplyBody) => void,
) {
try {
inspectRequest(uri, requestBody);
} catch (err) {
cb(err, '');
}
cb(null, replyBody);
};
}
public done() {
try {
// scope.done() will throw an error if there are expected api calls that have not happened.

View File

@ -38,7 +38,7 @@ interface TarFiles {
const itSkipWindows = process.platform === 'win32' ? it.skip : it;
describe('compare new and old tarDirectory implementations', async function() {
describe('compare new and old tarDirectory implementations', function() {
const extraContent = 'extra';
const extraEntry: tar.Headers = {
name: 'extra.txt',