Merge pull request #1301 from balena-io/cancellable-livepushes

Add cancellable livepushes, when a file changes during a current livepush
This commit is contained in:
CameronDiver 2019-06-10 03:36:51 -07:00 committed by GitHub
commit 7271f90dc6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 175 additions and 59 deletions

View File

@ -137,7 +137,9 @@ export class MarkdownFileParser {
} else { } else {
reject( reject(
new Error( new Error(
`Markdown section not found: title="${title}" file="${this.mdFilePath}"`, `Markdown section not found: title="${title}" file="${
this.mdFilePath
}"`,
), ),
); );
} }

View File

@ -11,13 +11,19 @@ process.env.UV_THREADPOOL_SIZE = '64';
// Use fast-boot to cache require lookups, speeding up startup // Use fast-boot to cache require lookups, speeding up startup
require('fast-boot2').start({ require('fast-boot2').start({
cacheFile: '.fast-boot.json' cacheFile: '.fast-boot.json',
}); });
require('coffeescript/register'); require('coffeescript/register');
const path = require('path');
const rootDir = path.join(__dirname, '..');
// Note: before ts-node v6.0.0, 'transpile-only' (no type checking) was the // Note: before ts-node v6.0.0, 'transpile-only' (no type checking) was the
// default option. We upgraded ts-node and found that adding 'transpile-only' // default option. We upgraded ts-node and found that adding 'transpile-only'
// was necessary to avoid a mysterious 'null' error message. On the plus side, // was necessary to avoid a mysterious 'null' error message. On the plus side,
// it is supposed to run faster. We still benefit from type checking when // it is supposed to run faster. We still benefit from type checking when
// running 'npm run build'. // running 'npm run build'.
require('ts-node/register/transpile-only'); require('ts-node').register({
project: path.join(rootDir, 'tsconfig.json'),
transpileOnly: true,
});
require('../lib/app').run(); require('../lib/app').run();

View File

@ -88,7 +88,9 @@ async function getAppOwner(sdk: BalenaSDK, appName: string) {
}); });
const selected = await selectFromList( const selected = await selectFromList(
`${entries.length} applications found with that name, please select the application you would like to push to`, `${
entries.length
} applications found with that name, please select the application you would like to push to`,
entries, entries,
); );

View File

@ -58,13 +58,17 @@ async function getContainerId(
}); });
if (request.status !== 200) { if (request.status !== 200) {
throw new Error( throw new Error(
`There was an error connecting to device ${uuid}, HTTP response code: ${request.status}.`, `There was an error connecting to device ${uuid}, HTTP response code: ${
request.status
}.`,
); );
} }
const body = request.body; const body = request.body;
if (body.status !== 'success') { if (body.status !== 'success') {
throw new Error( throw new Error(
`There was an error communicating with device ${uuid}.\n\tError: ${body.message}`, `There was an error communicating with device ${uuid}.\n\tError: ${
body.message
}`,
); );
} }
containerId = body.services[serviceName]; containerId = body.services[serviceName];

View File

@ -201,7 +201,9 @@ export const tunnel: CommandDefinition<Args, Options> = {
) )
.then(() => { .then(() => {
logger.logInfo( logger.logInfo(
` - tunnelling ${localAddress}:${localPort} to ${device.uuid}:${remotePort}`, ` - tunnelling ${localAddress}:${localPort} to ${
device.uuid
}:${remotePort}`,
); );
return true; return true;

View File

@ -45,7 +45,9 @@ function checkNodeVersion() {
const { stripIndent } = require('common-tags'); const { stripIndent } = require('common-tags');
console.warn(stripIndent` console.warn(stripIndent`
------------------------------------------------------------------------------ ------------------------------------------------------------------------------
Warning: Node version "${process.version}" does not match required versions "${validNodeVersions}". Warning: Node version "${
process.version
}" does not match required versions "${validNodeVersions}".
This may cause unexpected behaviour. To upgrade Node, visit: This may cause unexpected behaviour. To upgrade Node, visit:
https://nodejs.org/en/download/ https://nodejs.org/en/download/
------------------------------------------------------------------------------ ------------------------------------------------------------------------------

View File

@ -25,7 +25,9 @@ import { exitWithExpectedError } from './utils/patterns';
function routeCliFramework(argv: string[]): void { function routeCliFramework(argv: string[]): void {
if (process.env.DEBUG) { if (process.env.DEBUG) {
console.log( console.log(
`Debug: original argv0="${process.argv0}" argv=[${argv}] length=${argv.length}`, `Debug: original argv0="${process.argv0}" argv=[${argv}] length=${
argv.length
}`,
); );
} }
const cmdSlice = argv.slice(2); const cmdSlice = argv.slice(2);

View File

@ -51,7 +51,9 @@ export async function parseRegistrySecrets(
return registrySecrets; return registrySecrets;
} catch (error) { } catch (error) {
return exitWithExpectedError( return exitWithExpectedError(
`Error validating registry secrets file "${secretsFilename}":\n${error.message}`, `Error validating registry secrets file "${secretsFilename}":\n${
error.message
}`,
); );
} }
} }
@ -142,7 +144,9 @@ async function performResolution(
buildTask.buildStream = clonedStream; buildTask.buildStream = clonedStream;
if (!buildTask.external && !buildTask.resolved) { if (!buildTask.external && !buildTask.resolved) {
throw new Error( throw new Error(
`Project type for service "${buildTask.serviceName}" could not be determined. Missing a Dockerfile?`, `Project type for service "${
buildTask.serviceName
}" could not be determined. Missing a Dockerfile?`,
); );
} }
return buildTask; return buildTask;

View File

@ -92,7 +92,9 @@ async function environmentFromInput(
// exists // exists
if (!(match[1] in ret)) { if (!(match[1] in ret)) {
logger.logDebug( logger.logDebug(
`Warning: Cannot find a service with name ${match[1]}. Treating the string as part of the environment variable name.`, `Warning: Cannot find a service with name ${
match[1]
}. Treating the string as part of the environment variable name.`,
); );
match[2] = `${match[1]}:${match[2]}`; match[2] = `${match[1]}:${match[2]}`;
} else { } else {
@ -130,7 +132,9 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
await api.ping(); await api.ping();
} catch (e) { } catch (e) {
exitWithExpectedError( exitWithExpectedError(
`Could not communicate with local mode device at address ${opts.deviceHost}`, `Could not communicate with local mode device at address ${
opts.deviceHost
}`,
); );
} }
@ -290,8 +294,22 @@ export async function performBuilds(
logger.logDebug('Probing remote daemon for cache images'); logger.logDebug('Probing remote daemon for cache images');
await assignDockerBuildOpts(docker, buildTasks, opts); await assignDockerBuildOpts(docker, buildTasks, opts);
// If we're passed a build logs object make sure to set it
// up properly
let logHandlers: ((serviceName: string, line: string) => void) | undefined;
if (buildLogs != null) {
for (const task of buildTasks) {
if (!task.external) {
buildLogs[task.serviceName] = '';
}
}
logHandlers = (serviceName: string, line: string) => {
buildLogs[serviceName] += `${line}\n`;
};
}
logger.logDebug('Starting builds...'); logger.logDebug('Starting builds...');
await assignOutputHandlers(buildTasks, logger, buildLogs); await assignOutputHandlers(buildTasks, logger, logHandlers);
const localImages = await multibuild.performBuilds(buildTasks, docker); const localImages = await multibuild.performBuilds(buildTasks, docker);
// Check for failures // Check for failures
@ -324,11 +342,26 @@ export async function rebuildSingleTask(
composition: Composition, composition: Composition,
source: string, source: string,
opts: DeviceDeployOptions, opts: DeviceDeployOptions,
// To cancel a running build, you must first find the
// container id that it's running in. This is printed in
// the logs, so any calller who wants to keep track of
// this should provide the following callback
containerIdCb?: (id: string) => void,
): Promise<string> { ): Promise<string> {
const { tarDirectory } = await import('../compose'); const { tarDirectory } = await import('../compose');
const multibuild = await import('resin-multibuild'); const multibuild = await import('resin-multibuild');
// First we run the build task, to get the new image id // First we run the build task, to get the new image id
const buildLogs: Dictionary<string> = {}; let buildLogs = '';
const logHandler = (_s: string, line: string) => {
buildLogs += `${line}\n`;
if (containerIdCb != null) {
const match = line.match(/^\s*--->\s*Running\s*in\s*([a-f0-9]*)\s*$/i);
if (match != null) {
containerIdCb(match[1]);
}
}
};
const tarStream = await tarDirectory(source); const tarStream = await tarDirectory(source);
@ -342,7 +375,7 @@ export async function rebuildSingleTask(
} }
await assignDockerBuildOpts(docker, [task], opts); await assignDockerBuildOpts(docker, [task], opts);
await assignOutputHandlers([task], logger, buildLogs); await assignOutputHandlers([task], logger, logHandler);
const [localImage] = await multibuild.performBuilds([task], docker); const [localImage] = await multibuild.performBuilds([task], docker);
@ -355,13 +388,13 @@ export async function rebuildSingleTask(
]); ]);
} }
return buildLogs[task.serviceName]; return buildLogs;
} }
function assignOutputHandlers( function assignOutputHandlers(
buildTasks: BuildTask[], buildTasks: BuildTask[],
logger: Logger, logger: Logger,
buildLogs?: Dictionary<string>, logCb?: (serviceName: string, line: string) => void,
) { ) {
_.each(buildTasks, task => { _.each(buildTasks, task => {
if (task.external) { if (task.external) {
@ -372,9 +405,6 @@ function assignOutputHandlers(
); );
}; };
} else { } else {
if (buildLogs) {
buildLogs[task.serviceName] = '';
}
task.streamHook = stream => { task.streamHook = stream => {
stream.on('data', (buf: Buffer) => { stream.on('data', (buf: Buffer) => {
const str = _.trimEnd(buf.toString()); const str = _.trimEnd(buf.toString());
@ -384,10 +414,8 @@ function assignOutputHandlers(
logger, logger,
); );
if (buildLogs) { if (logCb) {
buildLogs[task.serviceName] = `${ logCb(task.serviceName, str);
buildLogs[task.serviceName]
}\n${str}`;
} }
} }
}); });

View File

@ -1,10 +1,7 @@
import * as Bluebird from 'bluebird'; import * as Bluebird from 'bluebird';
import * as chokidar from 'chokidar'; import * as chokidar from 'chokidar';
import * as Dockerode from 'dockerode'; import * as Dockerode from 'dockerode';
import Livepush, { import Livepush, { ContainerNotRunningError } from 'livepush';
ContainerNotRunningError,
LivepushAlreadyRunningError,
} from 'livepush';
import * as _ from 'lodash'; import * as _ from 'lodash';
import * as path from 'path'; import * as path from 'path';
import { Composition } from 'resin-compose-parse'; import { Composition } from 'resin-compose-parse';
@ -67,6 +64,10 @@ export class LivepushManager {
private updateEventsWaiting: Dictionary<string[]> = {}; private updateEventsWaiting: Dictionary<string[]> = {};
private deleteEventsWaiting: Dictionary<string[]> = {}; private deleteEventsWaiting: Dictionary<string[]> = {};
private rebuildsRunning: Dictionary<boolean> = {};
private rebuildRunningIds: Dictionary<string> = {};
private rebuildsCancelled: Dictionary<boolean> = {};
public constructor(opts: LivepushOpts) { public constructor(opts: LivepushOpts) {
this.buildContext = opts.buildContext; this.buildContext = opts.buildContext;
this.composition = opts.composition; this.composition = opts.composition;
@ -135,12 +136,6 @@ export class LivepushManager {
); );
} }
const msgString = (msg: string) =>
`[${getServiceColourFn(serviceName)(serviceName)}] ${msg}`;
const log = (msg: string) => this.logger.logLivepush(msgString(msg));
const error = (msg: string) => this.logger.logError(msgString(msg));
const debugLog = (msg: string) => this.logger.logDebug(msgString(msg));
const livepush = await Livepush.init( const livepush = await Livepush.init(
dockerfile, dockerfile,
context, context,
@ -149,22 +144,7 @@ export class LivepushManager {
this.docker, this.docker,
); );
livepush.on('commandExecute', command => this.assignLivepushOutputHandlers(serviceName, livepush);
log(`Executing command: \`${command.command}\``),
);
livepush.on('commandOutput', output =>
log(` ${output.output.data.toString()}`),
);
livepush.on('commandReturn', ({ returnCode, command }) => {
if (returnCode !== 0) {
error(` Command ${command} failed with exit code: ${returnCode}`);
} else {
debugLog(`Command ${command} exited successfully`);
}
});
livepush.on('containerRestart', () => {
log('Restarting service...');
});
this.updateEventsWaiting[serviceName] = []; this.updateEventsWaiting[serviceName] = [];
this.deleteEventsWaiting[serviceName] = []; this.deleteEventsWaiting[serviceName] = [];
@ -196,6 +176,9 @@ export class LivepushManager {
monitor, monitor,
containerId: container.containerId, containerId: container.containerId,
}; };
this.rebuildsRunning[serviceName] = false;
this.rebuildsCancelled[serviceName] = false;
} }
} }
@ -293,9 +276,7 @@ export class LivepushManager {
this.logger.logError( this.logger.logError(
`An error occured whilst trying to perform a livepush: `, `An error occured whilst trying to perform a livepush: `,
); );
if (e instanceof LivepushAlreadyRunningError) { if (e instanceof ContainerNotRunningError) {
this.logger.logError(' Livepush already running');
} else if (e instanceof ContainerNotRunningError) {
this.logger.logError(' Livepush container not running'); this.logger.logError(' Livepush container not running');
} else { } else {
this.logger.logError(` ${e.message}`); this.logger.logError(` ${e.message}`);
@ -305,6 +286,17 @@ export class LivepushManager {
} }
private async handleServiceRebuild(serviceName: string): Promise<void> { private async handleServiceRebuild(serviceName: string): Promise<void> {
if (this.rebuildsRunning[serviceName]) {
this.logger.logLivepush(
`Cancelling ongoing rebuild for service ${serviceName}`,
);
await this.cancelRebuild(serviceName);
while (this.rebuildsCancelled[serviceName]) {
await Bluebird.delay(1000);
}
}
this.rebuildsRunning[serviceName] = true;
try { try {
const buildTask = _.find(this.buildTasks, { serviceName }); const buildTask = _.find(this.buildTasks, { serviceName });
if (buildTask == null) { if (buildTask == null) {
@ -323,18 +315,32 @@ export class LivepushManager {
this.composition, this.composition,
this.buildContext, this.buildContext,
this.deployOpts, this.deployOpts,
id => {
this.rebuildRunningIds[serviceName] = id;
},
); );
} catch (e) { } catch (e) {
if (!(e instanceof BuildError)) { if (!(e instanceof BuildError)) {
throw e; throw e;
} }
if (this.rebuildsCancelled[serviceName]) {
return;
}
this.logger.logError( this.logger.logError(
`Rebuild of service ${serviceName} failed!\n Error: ${e.getServiceError( `Rebuild of service ${serviceName} failed!\n Error: ${e.getServiceError(
serviceName, serviceName,
)}`, )}`,
); );
return; return;
} finally {
delete this.rebuildRunningIds[serviceName];
}
// If the build has been cancelled, exit early
if (this.rebuildsCancelled[serviceName]) {
return;
} }
// Let's first delete the container from the device // Let's first delete the container from the device
@ -371,11 +377,63 @@ export class LivepushManager {
stageImages[serviceName], stageImages[serviceName],
this.docker, this.docker,
); );
this.assignLivepushOutputHandlers(serviceName, instance.livepush);
} catch (e) { } catch (e) {
this.logger.logError(`There was an error rebuilding the service: ${e}`); this.logger.logError(`There was an error rebuilding the service: ${e}`);
} finally {
this.rebuildsRunning[serviceName] = false;
this.rebuildsCancelled[serviceName] = false;
} }
} }
private async cancelRebuild(serviceName: string) {
this.rebuildsCancelled[serviceName] = true;
// If we have a container id of the current build,
// attempt to kill it
if (this.rebuildRunningIds[serviceName] != null) {
try {
await this.docker
.getContainer(this.rebuildRunningIds[serviceName])
.remove({ force: true });
await this.containers[serviceName].livepush.cancel();
} catch {
// No need to do anything here
}
}
}
private assignLivepushOutputHandlers(
serviceName: string,
livepush: Livepush,
) {
const msgString = (msg: string) =>
`[${getServiceColourFn(serviceName)(serviceName)}] ${msg}`;
const log = (msg: string) => this.logger.logLivepush(msgString(msg));
const error = (msg: string) => this.logger.logError(msgString(msg));
const debugLog = (msg: string) => this.logger.logDebug(msgString(msg));
livepush.on('commandExecute', command =>
log(`Executing command: \`${command.command}\``),
);
livepush.on('commandOutput', output =>
log(` ${output.output.data.toString()}`),
);
livepush.on('commandReturn', ({ returnCode, command }) => {
if (returnCode !== 0) {
error(` Command ${command} failed with exit code: ${returnCode}`);
} else {
debugLog(`Command ${command} exited successfully`);
}
});
livepush.on('containerRestart', () => {
log('Restarting service...');
});
livepush.on('cancel', () => {
log('Cancelling current livepush...');
});
}
private static extractDockerArrowMessage( private static extractDockerArrowMessage(
outputLine: string, outputLine: string,
): string | undefined { ): string | undefined {

View File

@ -45,9 +45,13 @@ export function stateToString(state: OperationState) {
switch (state.operation.command) { switch (state.operation.command) {
case 'copy': case 'copy':
return `${result} ${state.operation.from.path} -> ${state.operation.to.path}`; return `${result} ${state.operation.from.path} -> ${
state.operation.to.path
}`;
case 'replace': case 'replace':
return `${result} ${state.operation.file.path}, ${state.operation.copy} -> ${state.operation.replace}`; return `${result} ${state.operation.file.path}, ${
state.operation.copy
} -> ${state.operation.replace}`;
case 'run-script': case 'run-script':
return `${result} ${state.operation.script}`; return `${result} ${state.operation.script}`;
default: default:

View File

@ -279,7 +279,9 @@ function createRemoteBuildRequest(
if (response.statusCode >= 100 && response.statusCode < 400) { if (response.statusCode >= 100 && response.statusCode < 400) {
if (DEBUG_MODE) { if (DEBUG_MODE) {
console.log( console.log(
`[debug] received HTTP ${response.statusCode} ${response.statusMessage}`, `[debug] received HTTP ${response.statusCode} ${
response.statusMessage
}`,
); );
} }
} else { } else {

View File

@ -106,7 +106,7 @@
"gulp-shell": "^0.5.2", "gulp-shell": "^0.5.2",
"mochainon": "^2.0.0", "mochainon": "^2.0.0",
"pkg": "~4.3.8", "pkg": "~4.3.8",
"prettier": "^1.17.0", "prettier": "1.17.0",
"publish-release": "^1.6.0", "publish-release": "^1.6.0",
"resin-lint": "^3.0.1", "resin-lint": "^3.0.1",
"rewire": "^3.0.2", "rewire": "^3.0.2",
@ -160,7 +160,7 @@
"is-root": "^1.0.0", "is-root": "^1.0.0",
"js-yaml": "^3.13.1", "js-yaml": "^3.13.1",
"klaw": "^3.0.0", "klaw": "^3.0.0",
"livepush": "^1.2.3", "livepush": "^2.0.0",
"lodash": "^4.17.4", "lodash": "^4.17.4",
"minimatch": "^3.0.4", "minimatch": "^3.0.4",
"mixpanel": "^0.10.1", "mixpanel": "^0.10.1",