nci
Version:
Flexible, open source continuous integration server written in node.js
696 lines (581 loc) • 16.4 kB
JavaScript
;
var Steppy = require('twostep').Steppy,
_ = require('underscore'),
createNode = require('./node').createNode,
EventEmitter = require('events').EventEmitter,
inherits = require('util').inherits,
logger = require('./logger')('distributor');
function Distributor(params) {
this.nodes = params.nodes;
// queued projects to build
this.queue = [];
this.saveBuild = params.saveBuild || function(build, callback) {
callback(null, build);
};
this.removeBuild = params.removeBuild || function(build, callback) {
callback();
};
this.projects = params.projects;
this.notifier = params.notifier;
this.buildLogLineNumbersHash = {};
this.lastLinesHash = {};
this.inprogressBuildsHash = {};
}
inherits(Distributor, EventEmitter);
exports.Distributor = Distributor;
// do deferred initialization (e.g. create nodes after all plugins load)
Distributor.prototype.init = function() {
var self = this,
namesHash = {};
self.nodes = _(self.nodes).map(function(nodeParams) {
if (!nodeParams.name) {
nodeParams.name = nodeParams.type;
}
if (namesHash[nodeParams.name]) {
throw new Error('Node name `' + nodeParams.name + '` already used');
}
namesHash[nodeParams.name] = 1;
return self._createNode(nodeParams);
});
};
Distributor.prototype._createNode = function(params) {
return createNode(params);
};
Distributor.prototype._getBlockerExecutor = function(getBlockers, getTarget) {
var blockerExecutor;
_(this.nodes).find(function(node) {
blockerExecutor = _(node.executors).find(function(executor) {
var target = getTarget(executor);
return _(getBlockers(executor)).find(function(blocker) {
if (_(blocker).isRegExp()) {
return blocker.test(target);
} else {
return blocker === target;
}
});
});
return blockerExecutor;
});
return blockerExecutor;
};
// get wait reason for `project` with `params` when try to run it on `node`
Distributor.prototype._getExecutorWaitReason = function(node, project, params) {
// get node level wait reason
var waitReason = node.getExecutorWaitReason(project, params);
if (!waitReason) {
// get wait reason across all nodes
var blockerExecutor;
if (project.blockedBy) {
blockerExecutor = this._getBlockerExecutor(
function(executor) {
return project.blockedBy;
},
function(executor) {
return executor.project.name;
}
);
}
if (!blockerExecutor) {
blockerExecutor = this._getBlockerExecutor(
function(executor) {
return executor.project.blocks;
},
function(executor) {
return project.name;
}
);
}
if (blockerExecutor) {
waitReason = (
'blocked by currently running "' +
blockerExecutor.project.name + '"'
);
}
}
return waitReason;
};
Distributor.prototype._getNodeForScmChangesCheck = function(project, params) {
return _(this.nodes).find(function(node) {
return !node.getExecutorWaitReason(
project,
_({checkForScmChangesOnly: true}).defaults(params)
);
});
};
Distributor.prototype._runNext = function(callback) {
var self = this,
node,
executorId;
Steppy(
function() {
// update wait reasons for all queue items before run
self._updateWaitReasons();
var queueItemIndex = _(self.queue).findIndex(function(item) {
node = _(self.nodes).find(function(node) {
return !self._getExecutorWaitReason(
node,
item.project,
{env: item.build.env}
);
});
return node;
});
// quit if we have no suitable project
if (queueItemIndex === -1) {
return callback();
}
var queueItem = self.queue.splice(queueItemIndex, 1)[0],
build = queueItem.build;
// all main params (that can affect wait reason) must be passes to
// reserve
executorId = node.reserveExecutor(
build.project,
{env: build.env}
);
self._updateBuild(
build,
{
startDate: Date.now(),
status: 'in-progress',
waitReason: '',
node: _(node).pick('type', 'name')
},
this.slot()
);
},
function(err, build) {
this.pass(build);
// add some extra params to executor, some envVars
// that available only after build update(build number),
// also add env vars specified for the project and node
var executorParams = {
envVars: _({
NCI_BUILD_ID: String(build.id),
NCI_BUILD_NUMBER: String(build.number),
NCI_PROJECT_NAME: build.project.name,
NCI_NODE_NAME: build.node.name,
NCI_ENV_NAME: build.env ? build.env.name : ''
}).defaults(build.project.envVars, node.envVars)
};
var stepCallback = this.slot();
var executor = node.runExecutor(executorId, executorParams, function(err) {
if (err && !err.projectStepError) {
logger.error(
'Error during project execution: ', err.stack || err
);
}
var changes = {
endDate: Date.now(),
completed: true
};
// executor may not exist on error
if (executor && executor.canceled) {
changes.status = 'canceled';
changes.canceledBy = self.inprogressBuildsHash[build.id].canceledBy;
} else if (err) {
changes.status = 'error';
changes.error = _(err).pick('message', 'stderr');
} else {
changes.status = 'done';
}
self._updateBuild(
build,
changes,
function(err, build) {
delete self.inprogressBuildsHash[build.id];
if (err) {
logger.error(
'Error during build update: ', err.stack || err
);
return stepCallback(err);
}
self._onBuildComplete(build, stepCallback);
}
);
});
// executor may not exist on error
if (executor) {
self.inprogressBuildsHash[build.id] = {
build: build,
executor: executor
};
executor.on('currentStep', function(stepName) {
self._updateBuild(build, {currentStep: stepName});
});
executor.on('stepTimingsChange', function(stepTimings) {
self._updateBuild(build, {stepTimings: stepTimings});
});
executor.on('data', function(data) {
self._onBuildData(build, data);
});
executor.once('scmData', function(scmData) {
self._updateBuild(build, {scm: scmData});
// run the same project again if we don't reach the latest rev
if (!scmData.isLatest) {
self.run({
projectName: build.project.name,
initiator: {
type: 'build',
id: build.id,
number: build.number,
project: {name: build.project.name}
}
});
}
});
}
// update wait reasons for all queue items after run
self._updateWaitReasons();
},
function(err, build) {
// ensure release executor (e.g. on error) by releasing it manually
if (node && executorId) {
node.releaseExecutor(executorId);
}
callback(err, build);
}
);
};
Distributor.prototype._onBuildData = function(build, data) {
var self = this;
self.emit('buildData', build, data);
var cleanupText = function(text) {
return text.replace('\r', '');
};
var splittedData = data.split('\n'),
logLineNumber = self.buildLogLineNumbersHash[build.id] || 0;
self.lastLinesHash[build.id] = self.lastLinesHash[build.id] || '';
// if we don't have last line, so we start new line
if (!self.lastLinesHash[build.id]) {
logLineNumber++;
}
self.lastLinesHash[build.id] += _(splittedData).first();
var lines = [{
text: cleanupText(self.lastLinesHash[build.id]),
buildId: build.id,
number: logLineNumber
}];
if (splittedData.length > 1) {
// if we have last '' we have to take all except last
// this shown that string ends with eol
if (_(splittedData).last() === '') {
self.lastLinesHash[build.id] = '';
splittedData = _(splittedData.slice(1)).initial();
} else {
self.lastLinesHash[build.id] = _(splittedData).last();
splittedData = _(splittedData).tail();
}
lines = lines.concat(_(splittedData).map(function(line) {
return {
text: cleanupText(line),
buildId: build.id,
number: ++logLineNumber
};
}));
}
self.buildLogLineNumbersHash[build.id] = logLineNumber;
self.emit('buildLogLines', build, lines);
};
Distributor.prototype._updateWaitReasons = function() {
var self = this;
_(self.queue).each(function(item) {
var waitReasons = _(self.nodes).map(function(node) {
return self._getExecutorWaitReason(
node,
item.project,
{env: item.build.env}
);
});
var waitReason = _(waitReasons).compact().join(', ');
// set only non-empty and new reasons
if (waitReason && waitReason !== item.build.waitReason) {
self._updateBuild(item.build, {waitReason: waitReason});
}
});
};
Distributor.prototype._onBuildComplete = function(build, callback) {
var self = this;
Steppy(
function() {
// notify about build
self.notifier.send(build);
// process after build triggers
var triggerAfterGroup = this.makeGroup();
var after = build.project.trigger && build.project.trigger.after;
if (after) {
_(after).each(function(item) {
if (!item.status || item.status === build.status) {
self.run({
projectName: item.project,
initiator: {
type: 'build',
id: build.id,
number: build.number,
project: {name: build.project.name}
}
}, triggerAfterGroup.slot());
}
});
}
},
function(err, triggerAfterGroupResults) {
// try to run next project from the queue
self._runNext(this.slot());
},
callback
);
};
Distributor.prototype._emitBuildUpdate = function(
originalBuild, changes, build
) {
//@TODO: change event params on next major release - pass somithing like
// {originalBuild, changes, build} for every event
// always emit build update
this.emit('buildUpdated', build, changes);
if (
changes.status === 'queued' &&
originalBuild.status !== 'queued'
) {
this.emit('buildQueued', build);
}
if (
changes.status === 'in-progress' &&
originalBuild.status !== 'in-progress'
) {
this.emit('buildStarted', build);
}
if (changes.status && changes.status !== originalBuild.status) {
this.emit('buildStatusChanged', build);
}
if (changes.completed && !originalBuild.completed) {
this.emit('buildCompleted', build);
}
};
Distributor.prototype._updateBuild = function(build, changes, callback) {
var self = this;
callback = callback || _.noop;
var originalBuild = _(build).clone();
Steppy(
function() {
if (build.id && changes.status && changes.status !== build.status) {
logger.log(
'Build #%s (project "%s", env: %s) change status: %s -> %s',
build.id,
build.project.name,
build.env ? build.env.name : 'not set',
build.status,
changes.status
);
}
_(build).extend(changes);
// skip saving to db of unimportant data
if (changes.currentStep && _(changes).keys().length === 1) {
this.pass(null);
} else {
self.saveBuild(build, this.slot());
}
},
function() {
if (!originalBuild.id && build.id) {
logger.log(
'Build #%s (project "%s", env: %s) %s',
build.id,
build.project.name,
build.env ? build.env.name : 'not set',
build.status
);
}
// if number appear after save to db then add it to changes
// TODO: might be better to generate number right there (instead
// of hooks)
if (!originalBuild.number && build.number) {
changes.number = build.number;
}
// emits only after get an id (at save build)
self._emitBuildUpdate(originalBuild, changes, build);
this.pass(build);
},
callback
);
};
Distributor.prototype.cancel = function(params, callback) {
callback = callback || function(err) {
if (err) logger.error('Error during cancel: ', err.stack || err);
};
var self = this,
id = params.buildId;
Steppy(
function() {
var queueItemIndex = _(self.queue).findIndex(function(item) {
return item.build.id === id;
});
var build;
// cancel queued build
if (queueItemIndex !== -1) {
build = self.queue[queueItemIndex].build;
// remove from queue
self.queue.splice(queueItemIndex, 1);
// remove from db
self.removeBuild(build, this.slot());
} else if (self.inprogressBuildsHash[id]) {
// cancel in progress build
build = self.inprogressBuildsHash[id].build;
var executor = self.inprogressBuildsHash[id].executor;
executor.cancel(this.slot());
// put details about cancel to inprogress hash
self.inprogressBuildsHash[id].canceledBy = params.canceledBy;
} else {
throw new Error(
'Build with id "' + id + '" not found for cancel'
);
}
this.pass(build);
},
function(err, cancelResult, build) {
self.emit('buildCanceled', build);
this.pass(null);
},
callback
);
};
// apply build params to project configuration, returns new project config
// (doesn't modify source project)
Distributor.prototype._makeProject = function(project, buildParams) {
var newProject = _(project).clone();
if (buildParams.scmRev) {
newProject.scm = _({}).extend(project.scm, {rev: buildParams.scmRev});
}
return newProject;
};
Distributor.prototype.run = function(params, callback) {
callback = callback || function(err) {
if (err) logger.error('Error during run: ', err.stack || err);
};
var self = this,
project,
buildParams = params.buildParams || {},
env = params.env;
Steppy(
function() {
project = self._makeProject(
self.projects.get(params.projectName),
buildParams
);
if (project.archived) {
throw new Error('Can`t run archived project "' + project.name + '"');
}
if (env) {
// normalize env
if (_(env).isString()) {
env = {name: env};
}
} else if (project.envs) {
logger.log(
'Start individual builds for project "%s" envs: %s',
project.name,
_(project.envs).map(function(env) {
return JSON.stringify(env);
}).join(', ')
);
var buildGroupId = Date.now();
var groupRunBuilds = [];
// try to preserve envs order among queued builds
var funcs = _(project.envs).map(function(env) {
return function() {
var stepCallback = this.slot();
self.run(_({
env: env,
_groupId: buildGroupId
}).defaults(params), function(err, result) {
if (err) {
logger.error('Error during run: ', err.stack || err);
} else if (result && result.builds) {
groupRunBuilds = groupRunBuilds.concat(result.builds);
}
stepCallback();
});
};
});
funcs.push(function(err) {
var result = {};
if (!err) result.builds = groupRunBuilds;
callback(err, result);
});
Steppy.apply(null, funcs);
return;
}
if (params.withScmChangesOnly) {
var node = self._getNodeForScmChangesCheck(project, params);
if (node) {
logger.log(
'Check for scm changes for project "%s" on node "%s"...',
project.name,
node.name
);
node.hasScmChanges(project, params, this.slot());
} else {
logger.log(
'No suitable node for check changes for project "%s" ' +
'with params "%j", mark it as it has scm chagnes',
project.name, params
);
this.pass(true);
}
} else {
this.pass(null);
}
},
function(err, hasScmChanges) {
if (params.withScmChangesOnly && !hasScmChanges) {
logger.log(
'Building of "%s" skipped coz no scm changes',
project.name
);
return callback(null, {});
}
if (!params.queueQueued) {
var queuedItem = _(self.queue).find(function(item) {
return item.project.name === project.name;
});
if (queuedItem) {
logger.log(
'Building of "%s" skipped coz it`s already queued',
project.name
);
return callback(null, {});
}
}
var buildChanges = {
project: project,
initiator: params.initiator,
params: buildParams,
createDate: Date.now(),
status: 'queued',
completed: false
};
if (env) {
buildChanges.env = env;
}
if (params._groupId) {
buildChanges.groupId = params._groupId;
}
self._updateBuild({}, buildChanges, this.slot());
},
function(err, build) {
this.pass(build);
self.queue.push({project: project, build: build});
self._runNext(function(err) {
if (err) {
logger.error(
'Error during project run: ', err.stack || err
);
}
});
},
function(err, build) {
var result = {};
if (!err) result.builds = [build];
callback(err, result);
}
);
};