screwdriver-api
Version:
API server for the Screwdriver.cd service
322 lines (275 loc) • 11.7 kB
JavaScript
'use strict';
const boom = require('@hapi/boom');
const hoek = require('@hapi/hoek');
const merge = require('lodash.mergewith');
const { getFullStageJobName } = require('../../helper');
const STAGE_TEARDOWN_PATTERN = /^stage@([\w-]+)(?::teardown)$/;
const TERMINAL_STATUSES = ['FAILURE', 'ABORTED', 'UNSTABLE', 'COLLAPSED'];
const FINISHED_STATUSES = ['FAILURE', 'SUCCESS', 'ABORTED', 'UNSTABLE', 'COLLAPSED'];
/**
* @typedef {import('screwdriver-models/lib/build')} Build
* @typedef {import('screwdriver-models/lib/event')} Event
* @typedef {import('screwdriver-models/lib/step')} Step
*/
/**
* Identify whether this build resulted in a previously failed job to become successful.
*
* @method isFixedBuild
* @param {Build} build Build Object
* @param {JobFactory} jobFactory Job Factory instance
*/
async function isFixedBuild(build, jobFactory) {
if (build.status !== 'SUCCESS') {
return false;
}
const job = await jobFactory.get(build.jobId);
const failureBuild = await job.getLatestBuild({ status: 'FAILURE' });
const successBuild = await job.getLatestBuild({ status: 'SUCCESS' });
return !!((failureBuild && !successBuild) || failureBuild.id > successBuild.id);
}
/**
* Stops a frozen build from executing
*
* @method stopFrozenBuild
* @param {Build} build Build Object
* @param {String} previousStatus Previous build status
*/
async function stopFrozenBuild(build, previousStatus) {
if (previousStatus !== 'FROZEN') {
return Promise.resolve();
}
return build.stopFrozen(previousStatus);
}
/**
* Updates execution details for init step
*
* @method stopFrozenBuild
* @param {Build} build Build Object
* @param {Object} app Hapi app Object
* @returns {Promise<Step>} Updated step
*/
async function updateInitStep(build, app) {
const step = await app.stepFactory.get({ buildId: build.id, name: 'sd-setup-init' });
// If there is no init step, do nothing
if (!step) {
return null;
}
step.endTime = build.startTime || new Date().toISOString();
step.code = 0;
return step.update();
}
/**
* Set build status to desired status, set build statusMessage
*
* @param {Build} build Build Model
* @param {String} desiredStatus New Status
* @param {String} statusMessage User passed status message
* @param {String} statusMessageType User passed severity of the status message
* @param {String} username User initiating status build update
*/
function updateBuildStatus(build, desiredStatus, statusMessage, statusMessageType, username) {
const currentStatus = build.status;
// UNSTABLE -> SUCCESS needs to update meta and endtime.
// However, the status itself cannot be updated to SUCCESS
if (currentStatus === 'UNSTABLE') {
return;
}
if (desiredStatus !== undefined) {
build.status = desiredStatus;
}
switch (build.status) {
case 'ABORTED':
build.statusMessage =
currentStatus === 'FROZEN' ? `Frozen build aborted by ${username}` : `Aborted by ${username}`;
break;
case 'FAILURE':
case 'SUCCESS':
if (statusMessage) {
build.statusMessage = statusMessage;
build.statusMessageType = statusMessageType || null;
}
break;
default:
build.statusMessage = statusMessage || null;
build.statusMessageType = statusMessageType || null;
break;
}
}
/**
* Get stage for current node
*
* @param {StageFactory} stageFactory Stage factory
* @param {Object} workflowGraph Workflow graph
* @param {String} jobName Job name
* @param {Number} pipelineId Pipeline ID
* @return {Stage} Stage for node
*/
async function getStage({ stageFactory, workflowGraph, jobName, pipelineId }) {
const currentNode = workflowGraph.nodes.find(node => node.name === jobName);
let stage = null;
if (currentNode && currentNode.stageName) {
stage = await stageFactory.get({
pipelineId,
name: currentNode.stageName
});
}
return Promise.resolve(stage);
}
/**
* Get all builds in stage
*
* @param {Stage} stage Stage
* @param {Event} event Event
* @return {Promise<Build[]>} Builds in stage
*/
async function getStageJobBuilds({ stage, event }) {
// Get all jobIds for jobs in the stage
const stageJobIds = [...stage.jobIds, stage.setup];
// Get all builds in a stage for this event
return event.getBuilds({ params: { jobId: stageJobIds } });
}
/**
* Checks if all builds in stage are done running
* @param {Build[]} stageJobBuilds Builds in stage
* @returns {Boolean} Flag if stage is done
*/
function isStageDone(stageJobBuilds) {
let stageIsDone = false;
if (stageJobBuilds && stageJobBuilds.length !== 0) {
stageIsDone = !stageJobBuilds.some(b => !FINISHED_STATUSES.includes(b.status));
}
return stageIsDone;
}
/**
* Updates the build and trigger its downstream jobs in the workflow
*
* @method updateBuildAndTriggerDownstreamJobs
* @param {Object} config
* @param {Build} build
* @param {Object} server
* @param {String} username
* @param {Object} scmContext
* @returns {Promise<Build>} Updated build
*/
async function updateBuildAndTriggerDownstreamJobs(config, build, server, username, scmContext) {
const { buildFactory, eventFactory, jobFactory, stageFactory, stageBuildFactory } = server.app;
const { statusMessage, statusMessageType, stats, status: desiredStatus, meta } = config;
const { triggerNextJobs, removeJoinBuilds, createOrUpdateStageTeardownBuild } = server.plugins.builds;
const currentStatus = build.status;
const event = await eventFactory.get(build.eventId);
if (stats) {
// need to do this so the field is dirty
build.stats = Object.assign(build.stats, stats);
}
// Short circuit for cases that don't need to update status
if (!desiredStatus) {
build.statusMessage = statusMessage || build.statusMessage;
build.statusMessageType = statusMessageType || build.statusMessageType;
} else if (['SUCCESS', 'FAILURE', 'ABORTED'].includes(desiredStatus)) {
build.meta = meta || {};
event.meta = merge({}, event.meta, build.meta);
build.endTime = new Date().toISOString();
} else if (desiredStatus === 'RUNNING') {
build.startTime = new Date().toISOString();
} else if (desiredStatus === 'BLOCKED' && !hoek.reach(build, 'stats.blockedStartTime')) {
build.stats = Object.assign(build.stats, {
blockedStartTime: new Date().toISOString()
});
} else if (desiredStatus === 'QUEUED' && currentStatus !== 'QUEUED') {
throw boom.badRequest(`Cannot update builds to ${desiredStatus}`);
} else if (desiredStatus === 'BLOCKED' && currentStatus === 'BLOCKED') {
// Queue-Service can call BLOCKED status update multiple times
throw boom.badRequest(`Cannot update builds to ${desiredStatus}`);
}
let isFixed = Promise.resolve(false);
let stopFrozen = null;
updateBuildStatus(build, desiredStatus, statusMessage, statusMessageType, username);
// If status got updated to RUNNING or COLLAPSED, update init endTime and code
if (['RUNNING', 'COLLAPSED', 'FROZEN'].includes(desiredStatus)) {
await updateInitStep(build, server.app);
} else {
stopFrozen = stopFrozenBuild(build, currentStatus);
isFixed = isFixedBuild(build, jobFactory);
}
const [newBuild, newEvent] = await Promise.all([build.update(), event.update(), stopFrozen]);
const job = await newBuild.job;
const pipeline = await job.pipeline;
if (desiredStatus) {
await server.events.emit('build_status', {
settings: job.permutations[0].settings,
status: newBuild.status,
event: newEvent.toJson(),
pipeline: pipeline.toJson(),
jobName: job.name,
build: newBuild.toJson(),
buildLink: `${buildFactory.uiUri}/pipelines/${pipeline.id}/builds/${build.id}`,
isFixed: await isFixed
});
}
const skipFurther = /\[(skip further)\]/.test(newEvent.causeMessage);
// Update stageBuild status if it has changed;
// if stageBuild status is currently terminal, do not update
const stage = await getStage({
stageFactory,
workflowGraph: newEvent.workflowGraph,
jobName: job.name,
pipelineId: pipeline.id
});
const isStageTeardown = STAGE_TEARDOWN_PATTERN.test(job.name);
let stageBuildHasFailure = false;
if (stage) {
const stageBuild = await stageBuildFactory.get({
stageId: stage.id,
eventId: newEvent.id
});
if (stageBuild.status !== newBuild.status) {
if (!TERMINAL_STATUSES.includes(stageBuild.status)) {
stageBuild.status = newBuild.status;
await stageBuild.update();
}
}
stageBuildHasFailure = TERMINAL_STATUSES.includes(stageBuild.status);
}
// Guard against triggering non-successful or unstable builds
// Don't further trigger pipeline if intend to skip further jobs
if (newBuild.status !== 'SUCCESS' || skipFurther) {
// Check for failed jobs and remove any child jobs in created state
if (newBuild.status === 'FAILURE') {
await removeJoinBuilds({ pipeline, job, build: newBuild, event: newEvent, stage }, server.app);
if (stage && !isStageTeardown) {
await createOrUpdateStageTeardownBuild(
{ pipeline, job, build, username, scmContext, event, stage },
server.app
);
}
}
// Do not continue downstream is current job is stage teardown and statusBuild has failure
} else if (newBuild.status === 'SUCCESS' && isStageTeardown && stageBuildHasFailure) {
await removeJoinBuilds({ pipeline, job, build: newBuild, event: newEvent, stage }, server.app);
} else {
await triggerNextJobs({ pipeline, job, build: newBuild, username, scmContext, event: newEvent }, server.app);
}
// Determine if stage teardown build should start
// (if stage teardown build exists, and stageBuild.status is negative,
// and there are no active stage builds, and teardown build is not started)
if (stage && FINISHED_STATUSES.includes(newBuild.status)) {
const stageTeardownName = getFullStageJobName({ stageName: stage.name, jobName: 'teardown' });
const stageTeardownJob = await jobFactory.get({ pipelineId: pipeline.id, name: stageTeardownName });
const stageTeardownBuild = await buildFactory.get({ eventId: newEvent.id, jobId: stageTeardownJob.id });
// Start stage teardown build if stage is done
if (stageTeardownBuild && stageTeardownBuild.status === 'CREATED') {
const stageJobBuilds = await getStageJobBuilds({ stage, event: newEvent });
const stageIsDone = isStageDone(stageJobBuilds);
if (stageIsDone) {
stageTeardownBuild.status = 'QUEUED';
stageTeardownBuild.parentBuildId = stageJobBuilds.map(b => b.id);
await stageTeardownBuild.update();
await stageTeardownBuild.start();
}
}
}
return newBuild;
}
module.exports = {
updateBuildAndTriggerDownstreamJobs
};