screwdriver-api
Version:
API server for the Screwdriver.cd service
1,220 lines (1,053 loc) • 39.6 kB
JavaScript
;
const logger = require('screwdriver-logger');
const workflowParser = require('screwdriver-workflow-parser');
const merge = require('lodash.mergewith');
const schema = require('screwdriver-data-schema');
const { EXTERNAL_TRIGGER_ALL, STAGE_SETUP_PATTERN } = schema.config.regex;
const { getFullStageJobName } = require('../../helper');
const BUILD_STATUS_MESSAGES = {
SKIP_VIRTUAL_JOB: {
statusMessage: 'Skipped execution of the virtual job',
statusMessageType: 'INFO'
}
};
/**
* @typedef {import('screwdriver-models').JobFactory} JobFactory
* @typedef {import('screwdriver-models').BuildFactory} BuildFactory
* @typedef {import('screwdriver-models').EventFactory} EventFactory
* @typedef {import('screwdriver-models').PipelineFactory} PipelineFactory
* @typedef {import('screwdriver-models/lib/pipeline')} Pipeline
* @typedef {import('screwdriver-models/lib/event')} Event
* @typedef {import('screwdriver-models/lib/build')} Build
* @typedef {import('screwdriver-models/lib/job')} Job
*/
const Status = {
ABORTED: 'ABORTED',
CREATED: 'CREATED',
FAILURE: 'FAILURE',
QUEUED: 'QUEUED',
RUNNING: 'RUNNING',
SUCCESS: 'SUCCESS',
BLOCKED: 'BLOCKED',
UNSTABLE: 'UNSTABLE',
COLLAPSED: 'COLLAPSED',
FROZEN: 'FROZEN',
ENABLED: 'ENABLED',
isAborted(status) {
return status === this.ABORTED;
},
isCreated(status) {
return status === this.CREATED;
},
isFailure(status) {
return status === this.FAILURE;
},
isQueued(status) {
return status === this.QUEUED;
},
isRunning(status) {
return status === this.RUNNING;
},
isSuccess(status) {
return status === this.SUCCESS;
},
isBlocked(status) {
return status === this.BLOCKED;
},
isUnstable(status) {
return status === this.UNSTABLE;
},
isCollapsed(status) {
return status === this.COLLAPSED;
},
isFrozen(status) {
return status === this.FROZEN;
},
isEnabled(status) {
return status === this.ENABLED;
},
isStarted(status) {
return !['CREATED', null, undefined].includes(status);
}
};
/**
* Converts a string to an integer.
* Throws an error if the string is not a valid integer representation.
*
* @param {String} text The string to be converted to an integer.
* @returns {Number} The converted integer.
* @throws {Error} An error is thrown if the string can't be converted to a finite number.
*/
function strToInt(text) {
const value = Number.parseInt(text, 10);
if (Number.isFinite(value)) {
return value;
}
throw new Error(`Failed to cast '${text}' to integer`);
}
/**
* Delete a build
* @param {Object} buildConfig build object to delete
* @param {BuildFactory} buildFactory build factory
* @returns {Promise}
*/
async function deleteBuild(buildConfig, buildFactory) {
const buildToDelete = await buildFactory.get(buildConfig);
if (buildToDelete && Status.isCreated(buildToDelete.status)) {
return buildToDelete.remove();
}
return null;
}
/**
* Checks if job is external trigger
* @param {String} jobName Job name
* @returns {Boolean}
*/
function isExternalTrigger(jobName) {
return EXTERNAL_TRIGGER_ALL.test(jobName);
}
/**
* Checks if job has freezeWindows
* @param {Job} job Job object
* @returns {Boolean}
*/
function hasFreezeWindows(job) {
const { freezeWindows } = job.permutations[0];
return freezeWindows ? freezeWindows.length > 0 : false;
}
/**
* Get external pipelineId and job name from the `name`
* @param {String} name Job name
* @returns {{externalPipelineId: String, externalJobName: String}}
*/
function getExternalPipelineAndJob(name) {
const [, externalPipelineId, externalJobName] = EXTERNAL_TRIGGER_ALL.exec(name);
return { externalPipelineId, externalJobName };
}
/**
* Helper function to fetch external event from parentBuilds
* @param {Build} currentBuild Build for current completed job
* @param {String} pipelineId Pipeline ID for next job to be triggered.
* @param {EventFactory} eventFactory Factory for querying event data store.
* @returns {Promise<Event>} Event where the next job to be triggered belongs to.
*/
function getExternalEvent(currentBuild, pipelineId, eventFactory) {
if (!currentBuild.parentBuilds || !currentBuild.parentBuilds[pipelineId]) {
return null;
}
const { eventId } = currentBuild.parentBuilds[pipelineId];
return eventFactory.get(eventId);
}
/**
* Create event for downstream pipeline that need to be rebuilt
* @param {Object} config Configuration object
* @param {PipelineFactory} config.pipelineFactory Pipeline Factory
* @param {EventFactory} config.eventFactory Event Factory
* @param {Number} config.pipelineId Pipeline to be rebuilt
* @param {String} config.startFrom Job to be rebuilt
* @param {String} config.causeMessage Caused message, e.g. triggered by 1234(buildId)
* @param {String} config.parentBuildId ID of the build that triggers this event
* @param {Record<String, ParentBuild>} [config.parentBuilds] Builds that triggered this build
* @param {Number} [config.parentEventId] Parent event ID
* @param {Number} [config.groupEventId] Group parent event ID
* @returns {Promise<Event>} New event
*/
async function createEvent(config) {
const {
pipelineFactory,
eventFactory,
pipelineId,
startFrom,
skipMessage,
causeMessage,
parentBuildId,
parentBuilds,
parentEventId,
groupEventId
} = config;
const pipeline = await pipelineFactory.get(pipelineId);
const realAdmin = await pipeline.admin;
const { scmContext, scmUri } = pipeline;
// get pipeline admin's token
const token = await realAdmin.unsealToken();
const scmConfig = {
scmContext,
scmUri,
token
};
// Get commit sha
const { scm } = eventFactory;
const sha = await scm.getCommitSha(scmConfig);
const payload = {
pipelineId,
startFrom,
skipMessage,
type: 'pipeline',
causeMessage,
parentBuildId,
scmContext,
username: realAdmin.username,
sha,
...(parentEventId ? { parentEventId } : {}),
// for backward compatibility, this field is optional
...(parentBuilds ? { parentBuilds } : {}),
...(groupEventId ? { groupEventId } : {})
};
// Set configPipelineSha for child pipeline
if (pipeline.configPipelineId) {
const configPipeline = await pipelineFactory.get(pipeline.configPipelineId);
const configAdmin = await configPipeline.admin;
const configToken = await configAdmin.unsealToken();
const configScmConfig = {
scmContext: configPipeline.scmContext,
scmUri: configPipeline.scmUri,
token: configToken
};
payload.configPipelineSha = await scm.getCommitSha(configScmConfig);
}
return eventFactory.create(payload);
}
/**
* Create external event (returns event with `builds` field)
* @param {Object} config Configuration object
* @param {PipelineFactory} config.pipelineFactory Pipeline Factory
* @param {EventFactory} config.eventFactory Event Factory
* @param {Number} config.externalPipelineId External pipeline ID
* @param {String} config.startFrom External trigger to start from
* @param {String} config.skipMessage If this is set then build won't be created
* @param {Number} config.parentBuildId Parent Build ID
* @param {Object} config.parentBuilds Builds that triggered this build
* @param {String} config.causeMessage Cause message of this event
* @param {Number} [config.parentEventId] Parent event ID
* @param {Number} [config.groupEventId] Group parent event ID
* @returns {Promise<Event>}
*/
async function createExternalEvent(config) {
const {
pipelineFactory,
eventFactory,
externalPipelineId,
startFrom,
skipMessage,
parentBuildId,
parentBuilds,
causeMessage,
parentEventId,
groupEventId
} = config;
const createEventConfig = {
pipelineFactory,
eventFactory,
pipelineId: externalPipelineId,
startFrom,
skipMessage,
parentBuildId, // current build
causeMessage,
parentBuilds,
...(parentEventId ? { parentEventId } : {}),
...(groupEventId ? { groupEventId } : {})
};
return createEvent(createEventConfig);
}
/**
* Create internal build. If config.start is false or not passed in then do not start the job
* Need to pass in (jobName and pipelineId) or (jobId) to get job data
* @param {Object} config Configuration object
* @param {JobFactory} config.jobFactory Job Factory
* @param {BuildFactory} config.buildFactory Build Factory
* @param {Number} config.pipelineId Pipeline Id
* @param {String} config.jobName Job name
* @param {String} config.username Username of build
* @param {String} config.scmContext SCM context
* @param {Record<String, ParentBuild>} config.parentBuilds Builds that triggered this build
* @param {String|null} config.baseBranch Branch name
* @param {Number} config.parentBuildId Parent build ID
* @param {Boolean} config.start Whether to start the build or not
* @param {Number|undefined} config.jobId Job ID
* @param {EventModel} config.event Event build belongs to
* @param {String} config.causeMessage Reason the event is run
* @returns {Promise<BuildModel|null>}
*/
async function createInternalBuild(config) {
const {
jobFactory,
buildFactory,
pipelineId,
jobName,
username,
scmContext,
event,
parentBuilds,
start,
baseBranch,
parentBuildId,
jobId,
causeMessage
} = config;
const { ref = '', prSource = '', prBranchName = '', url = '' } = event.pr || {};
const prInfo = prBranchName ? { url, prBranchName } : '';
/** @type {Job} */
const job = jobId
? await jobFactory.get(jobId)
: await jobFactory.get({
name: jobName,
pipelineId
});
const internalBuildConfig = {
jobId: job.id,
sha: event.sha,
parentBuildId,
parentBuilds: parentBuilds || {},
eventId: event.id,
username,
configPipelineSha: event.configPipelineSha,
scmContext,
prRef: ref,
prSource,
prInfo,
start: start !== false,
baseBranch,
causeMessage
};
let jobState = job.state;
if (ref) {
// Whether a job is enabled is determined by the state of the original job.
// If the original job does not exist or archived, it will be enabled.
const originalJobName = job.parsePRJobName('job');
const originalJob = await jobFactory.get({
name: originalJobName,
pipelineId
});
jobState = originalJob && !originalJob.archived ? originalJob.state : Status.ENABLED;
}
if (Status.isEnabled(jobState)) {
// return build
return buildFactory.create(internalBuildConfig);
}
return null;
}
/**
* Return PR job or not
* PR job name certainly has ":". e.g. "PR-1:jobName"
* @param {String} jobName
* @returns {Boolean}
*/
function isPR(jobName) {
return jobName.startsWith('PR-');
}
/**
* Trim Job name to follow data-schema
* @param {String} jobName
* @returns {String} trimmed jobName
*/
function trimJobName(jobName) {
if (isPR(jobName)) {
return jobName.split(':')[1];
}
return jobName;
}
/**
* @typedef {Object} ParentBuild
* @property {String} eventId
* @property {Record<String, Number>} jobs Job name and build ID
*/
/**
* @typedef {Record<String, ParentBuild>} ParentBuilds
*/
/**
* Generates a parent builds object
* @param {Number} config.buildId Build ID
* @param {Number} config.eventId Event ID
* @param {Number} config.pipelineId Pipeline ID
* @param {String} config.jobName Job name
* @param {Array} [config.joinListNames] Job names in join list
* @returns {ParentBuilds} Returns parent builds object
*/
function createParentBuildsObj(config) {
const { buildId, eventId, pipelineId, jobName, joinListNames } = config;
// For getting multiple parent builds
if (!joinListNames) {
return { [pipelineId]: { eventId, jobs: { [jobName]: buildId } } };
}
const joinParentBuilds = {};
joinListNames.forEach(name => {
let parentBuildPipelineId = pipelineId;
let parentBuildJobName = name;
if (isExternalTrigger(name)) {
const { externalPipelineId, externalJobName } = getExternalPipelineAndJob(name);
parentBuildPipelineId = externalPipelineId;
parentBuildJobName = externalJobName;
}
joinParentBuilds[parentBuildPipelineId] = joinParentBuilds[parentBuildPipelineId] || {
eventId: null,
jobs: {}
};
joinParentBuilds[parentBuildPipelineId].jobs[parentBuildJobName] = null;
});
return joinParentBuilds;
}
/**
* Parse job info into important variables
* - parentBuilds: parent build information
* - joinListNames: array of join jobs
* - joinParentBuilds: parent build information for join jobs
* @param {Object} arg
* @param {Object} arg.joinObj Join object
* @param {Build} arg.currentBuild Object holding current event, job & pipeline
* @param {Pipeline} arg.currentPipeline Object holding current event, job & pipeline
* @param {Job} arg.currentJob Object holding current event, job & pipeline
* @param {String} arg.nextJobName Next job's name
* @param {Number} arg.nextPipelineId Next job's Pipeline Id
* @returns {{parentBuilds: ParentBuilds, joinListNames: String[], joinParentBuilds: ParentBuilds}}
*/
function parseJobInfo({ joinObj, currentBuild, currentPipeline, currentJob, nextJobName, nextPipelineId }) {
const joinList = joinObj && joinObj[nextJobName] && joinObj[nextJobName].join ? joinObj[nextJobName].join : [];
const joinListNames = joinList.map(j => j.name);
/* CONSTRUCT AN OBJ LIKE {111: {eventId: 2, D:987}}
* FOR EASY LOOKUP OF BUILD STATUS */
// current job's parentBuilds
const currentJobParentBuilds = currentBuild.parentBuilds || {};
// join jobs, with eventId and buildId empty
const joinParentBuilds = createParentBuildsObj({
pipelineId: nextPipelineId || currentPipeline.id,
joinListNames
});
// override currentBuild in the joinParentBuilds
const currentBuildInfo = createParentBuildsObj({
buildId: currentBuild.id,
eventId: currentBuild.eventId,
pipelineId: currentPipeline.id,
jobName: currentJob.name
});
// need to merge because it's possible same event has multiple builds
const parentBuilds = merge({}, joinParentBuilds, currentJobParentBuilds, currentBuildInfo);
return {
parentBuilds,
joinListNames
};
}
/**
* Get builds whose groupEventId is event.groupEventId. Only the latest build is retrieved for each job.
* @param {Number} groupEventId Group parent event ID
* @param {BuildFactory} buildFactory Build factory
* @returns {Promise<Build[]>} All finished builds
*/
async function getBuildsForGroupEvent(groupEventId, buildFactory) {
const builds = await buildFactory.getLatestBuilds({ groupEventId, readOnly: false });
builds.forEach(b => {
try {
if (typeof b.environment === 'string') {
b.environment = JSON.parse(b.environment);
}
if (typeof b.parentBuilds === 'string') {
b.parentBuilds = JSON.parse(b.parentBuilds);
}
if (typeof b.stats === 'string') {
b.stats = JSON.parse(b.stats);
}
if (typeof b.meta === 'string') {
b.meta = JSON.parse(b.meta);
}
if (typeof b.parentBuildId === 'string') {
b.parentBuildId = JSON.parse(b.parentBuildId);
}
if (b.parentBuildId) {
// parentBuildId could be the string '123', the number 123, or an array
b.parentBuildId = Array.isArray(b.parentBuildId)
? b.parentBuildId.map(Number)
: [Number(b.parentBuildId)];
}
} catch (err) {
logger.error(`Failed to parse objects for ${b.id}`);
}
});
return builds;
}
/**
* Update parent builds info when next build already exists
* @param {Object} arg
* @param {ParentBuilds} arg.joinParentBuilds Parent builds object for join job
* @param {Build} arg.nextBuild Next build
* @returns {Promise<Build>} Updated next build
*/
async function updateParentBuilds({ joinParentBuilds, nextBuild }) {
// Override old parentBuilds info
const newParentBuilds = merge({}, joinParentBuilds, nextBuild.parentBuilds, (objVal, srcVal) =>
// passthrough objects, else mergeWith mutates source
srcVal && typeof srcVal === 'object' ? undefined : objVal || srcVal
);
nextBuild.parentBuilds = newParentBuilds;
return nextBuild.update();
}
/**
* Get builds in join list from parent builds
* @param {newBuild} arg.newBuild Updated build
* @param {String[]} arg.joinListNames Join list names
* @param {Number} arg.pipelineId Pipeline ID
* @param {BuildFactory} arg.buildFactory Build factory
* @returns {Promise<Map<String, Build>>} Join builds
*/
async function getJoinBuilds({ newBuild, joinListNames, pipelineId, buildFactory }) {
const upstream = newBuild.parentBuilds || {};
const joinBuilds = {};
for (const jobName of joinListNames) {
let upstreamPipelineId = pipelineId;
let upstreamJobName = jobName;
if (isExternalTrigger(upstreamJobName)) {
const { externalPipelineId, externalJobName } = getExternalPipelineAndJob(jobName);
upstreamPipelineId = externalPipelineId;
upstreamJobName = externalJobName;
}
if (upstream[upstreamPipelineId] && upstream[upstreamPipelineId].jobs[upstreamJobName]) {
const buildId = upstream[upstreamPipelineId].jobs[upstreamJobName];
const build = await buildFactory.get(buildId);
if (typeof build.endTime === 'string') {
build.endTime = new Date(build.endTime);
}
joinBuilds[jobName] = build;
}
}
return joinBuilds;
}
/**
* Check if all parent builds of the new build are done
* @param {Object} arg
* @param {String[]} arg.joinListNames Join list names
* @param {String[]} arg.joinBuilds Join builds
* @returns {Promise<{hasFailure: Boolean, done: Boolean}>} Object with done and hasFailure statuses
*/
async function getParentBuildStatus({ joinListNames, joinBuilds }) {
// If buildId is empty, the job hasn't executed yet and the join is not done
const isExecuted = joinListNames.every(name => joinBuilds[name] !== undefined);
const parentBuilds = Object.values(joinBuilds);
const hasFailure = parentBuilds
.map(build => {
// Do not need to run the next build; terminal status
return [Status.FAILURE, Status.ABORTED, Status.COLLAPSED, Status.UNSTABLE].includes(build.status);
})
.includes(true);
const isDoneStatus = parentBuilds.every(build => {
// All builds are done
return [Status.FAILURE, Status.SUCCESS, Status.ABORTED, Status.UNSTABLE, Status.COLLAPSED].includes(
build.status
);
});
const done = isExecuted && isDoneStatus;
return { hasFailure, done };
}
/**
* Handle new build logic: update, start, or remove
* If the build is done, check if it has a failure:
* if failure, delete new build
* if no failure, start new build
* Otherwise, do nothing
* @param {Object} arg If the build is done or not
* @param {String[]} arg.joinListNames Join list names
* @param {Build} arg.newBuild Next build
* @param {Job} arg.job Next job
* @param {String|undefined} arg.pipelineId Pipeline ID
* @param {String|undefined} arg.stageName Stage name
* @param {Boolean} arg.isVirtualJob If the job is virtual or not
* @param {Event} arg.event Event
* @param {BuildFactory} arg.buildFactory Build factory
* @returns {Promise<Build|null>} The newly updated/created build
*/
async function handleNewBuild({
joinListNames,
newBuild,
job,
pipelineId,
stageName,
isVirtualJob,
event,
buildFactory
}) {
const joinBuilds = await getJoinBuilds({
newBuild,
joinListNames,
pipelineId,
buildFactory
});
/* CHECK IF ALL PARENT BUILDS OF NEW BUILD ARE DONE */
const { hasFailure, done } = await getParentBuildStatus({
joinBuilds,
joinListNames
});
if (!done || Status.isStarted(newBuild.status)) {
return null;
}
// Delete new build since previous build failed
if (hasFailure) {
const stageTeardownName = stageName ? getFullStageJobName({ stageName, jobName: 'teardown' }) : '';
// New build is not stage teardown job
if (job.name !== stageTeardownName) {
logger.info(
`Failure occurred in upstream job, removing new build - build:${newBuild.id} pipeline:${pipelineId}-${job.name} event:${newBuild.eventId} `
);
await newBuild.remove();
}
return null;
}
/* Prepare to execute the build */
const parentBuilds = Object.values(joinBuilds);
parentBuilds.sort((l, r) => {
if (l.endTime && r.endTime) {
return l.endTime.getTime() - r.endTime.getTime();
}
// Move to tail if endTime is not set
return (l.endTime ? 0 : 1) - (r.endTime ? 0 : 1);
});
newBuild.parentBuildId = parentBuilds.map(build => build.id);
// Bypass execution of the build if the job is virtual
if (isVirtualJob && !hasFreezeWindows(job)) {
newBuild.status = Status.SUCCESS;
newBuild.statusMessage = BUILD_STATUS_MESSAGES.SKIP_VIRTUAL_JOB.statusMessage;
newBuild.statusMessageType = BUILD_STATUS_MESSAGES.SKIP_VIRTUAL_JOB.statusMessageType;
// The virtual job does not inherit metadata because the Launcher is not executed.
// Therefore, it is necessary to take over the metadata from the previous build.
newBuild.meta = parentBuilds.reduce((acc, build) => merge(acc, build.meta), {});
return newBuild.update();
}
// All join builds finished successfully, and it's clear that a new build has not been started before.
// Start new build.
newBuild.status = Status.QUEUED;
await newBuild.update();
const causeMessage = job.name === event.startFrom ? event.causeMessage : '';
return newBuild.start({ causeMessage });
}
/**
* Get all builds with a given event ID as the parentEventID
* @param {Object} arg
* @param {EventFactory} eventFactory Event factory
* @param {Number} parentEventId Parent event ID
* @param {Number} pipelineId Pipeline ID
* @returns {Promise<Build[]>} Array of builds with same parent event ID
*/
async function getParallelBuilds({ eventFactory, parentEventId, pipelineId }) {
let parallelEvents = await eventFactory.list({
params: {
parentEventId
}
});
// Remove previous events from same pipeline
parallelEvents = parallelEvents.filter(pe => pe.pipelineId !== pipelineId);
// Fetch builds for each parallel event and combine them into one array
const parallelBuildsPromises = parallelEvents.map(pe => pe.getBuilds());
const parallelBuildsArrays = await Promise.all(parallelBuildsPromises);
// Flatten the array of arrays into a single array
const parallelBuilds = [].concat(...parallelBuildsArrays);
return parallelBuilds;
}
/**
* Get all events with a given event ID and pipeline ID
* @param {Object} arg
* @param {EventFactory} eventFactory Event factory
* @param {Number} parentEventId Parent event ID
* @param {Number} pipelineId Pipeline ID
* @returns {Promise<Event[]>} Array of events with same parent event ID and same pipeline ID
*/
async function getSameParentEvents({ eventFactory, parentEventId, pipelineId }) {
const parallelEvents = await eventFactory.list({
params: {
parentEventId
}
});
return parallelEvents.filter(pe => strToInt(pe.pipelineId) === pipelineId);
}
/**
* Get subsequent job names which the root is the start from node
* @param {Array} [workflowGraph] Array of graph vertices
* @param {Array} [workflowGraph.nodes] Array of graph vertices
* @param {Array} [workflowGraph.edges] Array of graph edges
* @param {String} [startNode] Starting/trigger node
* @returns {Array<String>} subsequent job names
*/
function getSubsequentJobs(workflowGraph, startNode) {
const { nodes, edges } = workflowGraph;
// startNode can be a PR job in PR events, so trim PR prefix from node name
if (!startNode || !nodes.length) {
return [];
}
const nodeToEdgeDestsMap = Object.fromEntries(nodes.map(node => [node.name, []]));
let start = trimJobName(startNode);
// In rare cases, WorkflowGraph and startNode may have different start tildes
if (!(start in nodeToEdgeDestsMap)) {
if (start.startsWith('~')) {
start = start.slice(1);
} else {
start = `~${start}`;
}
}
if (!(start in nodeToEdgeDestsMap)) {
return [];
}
const visiting = [start];
const visited = new Set(visiting);
edges.forEach(edge => {
// this is a temporary fix for the issue where the edge.src is not in the nodes array
// TODO: https://github.com/screwdriver-cd/screwdriver/issues/3206
if (!nodeToEdgeDestsMap[edge.src]) {
nodeToEdgeDestsMap[edge.src] = [];
}
nodeToEdgeDestsMap[edge.src].push(edge.dest);
});
if (edges.length) {
while (visiting.length) {
const currentNode = visiting.pop();
const dests = nodeToEdgeDestsMap[currentNode];
dests.forEach(dest => {
if (!visited.has(dest)) {
visiting.push(dest);
visited.add(dest);
}
});
}
}
visited.delete(start);
return [...visited];
}
/**
* Merge parentBuilds object with missing job information from latest builds object
* @param {ParentBuilds} parentBuilds parent builds
* @param {Build[]} relatedBuilds Related builds which is used to fill parentBuilds data
* @param {Event} currentEvent Current event
* @param {Event} nextEvent Next triggered event (Remote trigger or Same pipeline event triggered as external)
* @returns {ParentBuilds} Merged parent builds { "${pipelineId}": { jobs: { "${jobName}": ${buildId} }, eventId: 123 } }
*
* @example
* >>> mergeParentBuilds(...)
* {
* "1": {
* jobs: { "job-name-a": 1, "job-name-b": 2 }
* eventId: 123
* },
* "2": {
* jobs: { "job-name-a": 4, "job-name-b": 5 }
* eventId: 456
* },
* }
*/
function mergeParentBuilds(parentBuilds, relatedBuilds, currentEvent, nextEvent) {
const newParentBuilds = {};
const ignoreJobs =
nextEvent && currentEvent.startFrom.startsWith('~')
? getSubsequentJobs(nextEvent.workflowGraph, nextEvent.startFrom)
: getSubsequentJobs(currentEvent.workflowGraph, currentEvent.startFrom);
Object.entries(parentBuilds).forEach(([pipelineId, { jobs, eventId }]) => {
const newBuilds = {
jobs,
eventId
};
Object.entries(jobs).forEach(([jobName, build]) => {
if (build !== null) {
newBuilds.jobs[jobName] = build;
return;
}
let { workflowGraph } = currentEvent;
let nodeName = trimJobName(jobName);
if (strToInt(pipelineId) !== strToInt(currentEvent.pipelineId)) {
if (nextEvent) {
if (strToInt(pipelineId) !== strToInt(nextEvent.pipelineId)) {
nodeName = `sd@${pipelineId}:${nodeName}`;
}
workflowGraph = nextEvent.workflowGraph;
} else {
nodeName = `sd@${pipelineId}:${nodeName}`;
}
}
const targetJob = workflowGraph.nodes.find(node => node.name === nodeName);
if (!targetJob) {
logger.warn(`Job ${jobName}:${pipelineId} not found in workflowGraph for event ${currentEvent.id}`);
return;
}
const targetBuild = relatedBuilds.find(b => b.jobId === targetJob.id);
if (!targetBuild) {
logger.warn(`Job ${jobName}:${pipelineId} not found in builds`);
return;
}
if (!ignoreJobs.includes(nodeName) || targetBuild.eventId === currentEvent.id) {
newBuilds.jobs[jobName] = targetBuild.id;
newBuilds.eventId = targetBuild.eventId;
}
});
newParentBuilds[pipelineId] = newBuilds;
});
return newParentBuilds;
}
/**
* @typedef {Object} JoinPipeline
* @property {String} event event id
* @property {Record<String, {id: String, join: String[]}>} jobs
*/
/**
* @typedef {Record<String, JoinPipeline>} JoinPipelines
*/
/**
* Create joinObject for nextJobs to trigger
* For A & D in nextJobs for currentJobName B, create
* {A:[B,C], D:[B,F], X: []} where [B,C] join on A,
* [B,F] join on D and X has no join
* This can include external jobs
* @param {String[]} nextJobNames List of jobs to run next from workflow parser.
* @param {Object} current Object holding current job's build, event data
* @param {Build} current.build Current build
* @param {Event} current.event Current event
* @param {Pipeline} current.pipeline Current pipeline
* @param {EventFactory} eventFactory Object for querying DB for event data
* @returns {Promise<JoinPipelines>} Object representing join data for next jobs grouped by pipeline id
*
* @example
* >>> await createJoinObject(...)
* {
* "{pipelineId}" :{
* event: "{externalEventId}",
* jobs: {
* "{nextJobName}": {
* id: "{jobId}"
* join: ["{joinJobName1}", "{joinJobName2}"]
* }
* }
* }
*/
async function createJoinObject(nextJobNames, current, eventFactory) {
const { build, event, pipeline } = current;
const joinObj = {};
for (const jobName of nextJobNames) {
let nextJobPipelineId = pipeline.id;
let nextJobName = jobName;
let isExternal = false;
if (isExternalTrigger(jobName)) {
const { externalPipelineId, externalJobName } = getExternalPipelineAndJob(jobName);
nextJobPipelineId = externalPipelineId;
nextJobName = externalJobName;
isExternal = true;
}
const jId = event.workflowGraph.nodes.find(n => n.name === trimJobName(jobName)).id;
if (!joinObj[nextJobPipelineId]) joinObj[nextJobPipelineId] = {};
const pipelineObj = joinObj[nextJobPipelineId];
let jobs;
if (nextJobPipelineId !== pipeline.id) {
jobs = [];
const externalEvent = pipelineObj.event || (await getExternalEvent(build, nextJobPipelineId, eventFactory));
if (externalEvent) {
pipelineObj.event = externalEvent;
jobs = workflowParser.getSrcForJoin(externalEvent.workflowGraph, { jobName: nextJobName });
}
} else {
jobs = workflowParser.getSrcForJoin(event.workflowGraph, { jobName });
}
if (!pipelineObj.jobs) pipelineObj.jobs = {};
pipelineObj.jobs[nextJobName] = { id: jId, join: jobs, isExternal };
}
return joinObj;
}
/**
* Create stage teardown build if it doesn't already exist
* @param {Object} arg
* @param {JobFactory} arg.jobFactory Job factory
* @param {BuildFactory} arg.buildFactory Build factory
* @param {Object} arg.current Current object
* @param {Event} arg.current.event Current event
* @param {ParentBuilds} arg.parentBuilds Parent builds
* @param {String} arg.stageTeardownName Stage teardown name
* @param {String} arg.username Username
* @param {String} arg.scmContext SCM context
*/
async function ensureStageTeardownBuildExists({
jobFactory,
buildFactory,
current,
parentBuilds,
stageTeardownName,
username,
scmContext
}) {
// Check if stage teardown build already exists
const stageTeardownJob = await jobFactory.get({
pipelineId: current.pipeline.id,
name: stageTeardownName
});
const existingStageTeardownBuild = await buildFactory.get({
eventId: current.event.id,
jobId: stageTeardownJob.id
});
// Doesn't exist, create stage teardown job
if (!existingStageTeardownBuild) {
await createInternalBuild({
jobFactory,
buildFactory,
pipelineId: current.pipeline.id,
jobName: stageTeardownName,
username,
scmContext,
parentBuilds,
parentBuildId: current.build.id,
event: current.event, // this is the parentBuild for the next build
baseBranch: current.event.baseBranch || null,
start: false
});
} else {
await updateParentBuilds({
joinParentBuilds: parentBuilds,
nextBuild: existingStageTeardownBuild,
build: current.build
});
}
}
/**
* Extract a current pipeline's next jobs from pipeline join data
* (Next jobs triggered as external are not included)
*
* @param {JoinPipelines} joinedPipelines
* @param {Number} currentPipelineId
* @returns {JoinPipeline}
*/
function extractCurrentPipelineJoinData(joinedPipelines, currentPipelineId) {
const currentPipelineJoinData = joinedPipelines[currentPipelineId.toString()];
if (currentPipelineJoinData === undefined) {
return {};
}
return Object.fromEntries(Object.entries(currentPipelineJoinData.jobs).filter(([, join]) => !join.isExternal));
}
/**
* Extract next jobs in current and external pipelines from pipeline join data
*
* @param {JoinPipelines} joinedPipelines
* @param {Number} currentPipelineId
* @returns {JoinPipelines}
*/
function extractExternalJoinData(joinedPipelines, currentPipelineId) {
const externalJoinData = {};
Object.entries(joinedPipelines).forEach(([joinedPipelineId, joinedPipeline]) => {
const isExternalPipeline = strToInt(joinedPipelineId) !== currentPipelineId;
if (isExternalPipeline) {
externalJoinData[joinedPipelineId] = joinedPipeline;
} else {
const nextJobsTriggeredAsExternal = Object.entries(joinedPipeline.jobs).filter(
([, join]) => join.isExternal
);
if (nextJobsTriggeredAsExternal.length === 0) {
return;
}
externalJoinData[joinedPipelineId] = {
jobs: Object.fromEntries(nextJobsTriggeredAsExternal),
event: joinedPipeline.event
};
}
});
return externalJoinData;
}
/**
* Get job from job name
* @param {String} jobName Job name
* @param {String} pipelineId Pipeline id
* @param {JobFactory} jobFactory Job factory
* @returns {Promise<Job>}
*/
async function getJob(jobName, pipelineId, jobFactory) {
return jobFactory.get({
name: jobName,
pipelineId
});
}
/**
* @typedef {Object} WorkflowGraph
* @property {Array<{src: String, dest: String, join: Boolean}} edges
* @property {Array<{name: String, id: Number}>} nodes
*/
/**
* Check trigger is OR trigger
* @param {WorkflowGraph} workflowGraph
* @param {String} currentJobName current job name
* @param {String} nextJobName next job name
* @returns {Boolean}
*/
function isOrTrigger(workflowGraph, currentJobName, nextJobName) {
return workflowGraph.edges.some(edge => {
return edge.src === currentJobName && edge.dest === nextJobName && edge.join !== true;
});
}
/**
* Filter builds to restart
* @param {JoinPipeline} joinPipeline join job names
* @param {Build[]} groupEventBuilds Builds belong to current event group
* @param {Event} currentEvent Current event
* @param {Build} currentBuild Current build
* @returns {Build[]}
*/
function buildsToRestartFilter(joinPipeline, groupEventBuilds, currentEvent, currentBuild) {
return Object.values(joinPipeline.jobs)
.map(joinJob => {
// Next triggered job's build belonging to same event group
const existBuild = groupEventBuilds.find(build => build.jobId === joinJob.id);
// If there is no same job's build, then first time trigger
if (!existBuild) return null;
// CREATED build is not triggered yet
if (Status.isCreated(existBuild.status)) return null;
// Exist build is triggered from current build
// Prevent double triggering same build object
if (existBuild.parentBuildId.includes(currentBuild.id)) return null;
// Circle back trigger (Remote Join case)
if (existBuild.eventId === currentEvent.parentEventId) return null;
return existBuild;
})
.filter(build => build !== null);
}
/**
* Check if the job is setup job with setup suffix
* @param {String} jobName Job name
* @return {Boolean}
*/
function isStageSetup(jobName) {
return STAGE_SETUP_PATTERN.test(jobName);
}
/**
* get the stage name of a job
* @param {String} jobName Job name
* @param {Object} workflowGraph Workflow Graph
* @return {String} Stage name
*/
function getStageName(workflowGraph, jobName) {
const jobNode = workflowGraph.nodes.find(n => n.name === jobName);
return jobNode ? jobNode.stageName : null;
}
/**
* Check if the current job is a stage setup and the next job is a non-setup job in the same stage
* @param {String} currentJobName Current job
* @param {String} eventStartFrom Event StartFrom job
* @param {Object} workflowGraph Workflow Graph
* @return {Boolean}
*/
function isStartFromMiddleOfCurrentStage(currentJobName, eventStartFrom, workflowGraph) {
const startFromStageName = getStageName(workflowGraph, eventStartFrom);
const currentStageName = getStageName(workflowGraph, currentJobName);
return isStageSetup(currentJobName) && !isStageSetup(eventStartFrom) && startFromStageName === currentStageName;
}
module.exports = {
Status,
parseJobInfo,
createInternalBuild,
getParallelBuilds,
getSameParentEvents,
mergeParentBuilds,
updateParentBuilds,
getJoinBuilds,
getParentBuildStatus,
handleNewBuild,
ensureStageTeardownBuildExists,
getBuildsForGroupEvent,
createJoinObject,
createExternalEvent,
strToInt,
createEvent,
deleteBuild,
getJob,
isOrTrigger,
extractCurrentPipelineJoinData,
extractExternalJoinData,
buildsToRestartFilter,
trimJobName,
isStartFromMiddleOfCurrentStage,
hasFreezeWindows,
BUILD_STATUS_MESSAGES
};