From d12977ba7d6469c2ca79437eb26f62eb580b3c5b Mon Sep 17 00:00:00 2001 From: Dayanand Sagar Date: Thu, 12 Dec 2024 09:43:27 -0800 Subject: [PATCH 1/2] feat(3181): Skip execution of a virtual job when an event is started from that job --- package.json | 2 +- plugins/builds/helper/updateBuild.js | 304 +++++++++++++++++++++++++++ plugins/builds/update.js | 290 +------------------------ plugins/events/create.js | 19 ++ test/plugins/data/builds.json | 37 ++++ test/plugins/data/events.json | 4 +- test/plugins/events.test.js | 114 +++++++++- 7 files changed, 485 insertions(+), 285 deletions(-) create mode 100644 plugins/builds/helper/updateBuild.js diff --git a/package.json b/package.json index 0bf996393..2c020d8b1 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "screwdriver-executor-queue": "^5.0.0", "screwdriver-executor-router": "^4.0.0", "screwdriver-logger": "^2.0.0", - "screwdriver-models": "^30.2.0", + "screwdriver-models": "^31.0.0", "screwdriver-notifications-email": "^4.0.0", "screwdriver-notifications-slack": "^6.0.0", "screwdriver-request": "^2.0.1", diff --git a/plugins/builds/helper/updateBuild.js b/plugins/builds/helper/updateBuild.js new file mode 100644 index 000000000..67a5d4b64 --- /dev/null +++ b/plugins/builds/helper/updateBuild.js @@ -0,0 +1,304 @@ +'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 + */ + +/** + * Identify whether this build resulted in a previously failed job to become successful. + * + * @method isFixedBuild + * @param build Build Object + * @param 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' }); + + if ((failureBuild && !successBuild) || failureBuild.id > successBuild.id) { + return true; + } + + return false; +} + +/** + * Stops a frozen build from executing + * @method stopFrozenBuild + * @param {Object} build Build Object + * @param {String} previousStatus Prevous 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 {Object} build Build Object + * @param {Object} app Hapi app Object + */ +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 {Object} 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) { + // UNSTABLE -> SUCCESS needs to update meta and endtime. + // However, the status itself cannot be updated to SUCCESS + const currentStatus = build.status; + + if (currentStatus !== 'UNSTABLE') { + if (desiredStatus !== undefined) { + build.status = desiredStatus; + } + if (build.status === 'ABORTED') { + if (currentStatus === 'FROZEN') { + build.statusMessage = `Frozen build aborted by ${username}`; + } else { + build.statusMessage = `Aborted by ${username}`; + } + } else if (build.status === 'FAILURE' || build.status === 'SUCCESS') { + if (statusMessage) { + build.statusMessage = statusMessage; + build.statusMessageType = statusMessageType || null; + } + } else { + build.statusMessage = statusMessage || null; + build.statusMessageType = statusMessageType || null; + } + } +} + +/** + * 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); +} + +/** + * Checks if all builds in stage are done running + * @param {Object} stage Stage + * @param {Object} event Event + * @return {Boolean} Flag if stage is done + */ +async function isStageDone({ stage, event }) { + // Get all jobIds for jobs in the stage + const stageJobIds = stage.jobIds; + + stageJobIds.push(stage.setup); + + // Get all builds in a stage for this event + const stageJobBuilds = await event.getBuilds({ params: { jobId: stageJobIds } }); + 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} 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 stageIsDone = await isStageDone({ stage, event: newEvent }); + + if (stageIsDone) { + stageTeardownBuild.status = 'QUEUED'; + await stageTeardownBuild.update(); + await stageTeardownBuild.start(); + } + } + } + + return newBuild; +} + +module.exports = { + updateBuildAndTriggerDownstreamJobs +}; diff --git a/plugins/builds/update.js b/plugins/builds/update.js index 98d41869f..d823a06e5 100644 --- a/plugins/builds/update.js +++ b/plugins/builds/update.js @@ -1,72 +1,11 @@ 'use strict'; const boom = require('@hapi/boom'); -const hoek = require('@hapi/hoek'); const schema = require('screwdriver-data-schema'); const joi = require('joi'); const idSchema = schema.models.build.base.extract('id'); -const merge = require('lodash.mergewith'); -const { getScmUri, getUserPermissions, 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']; - -/** - * Identify whether this build resulted in a previously failed job to become successful. - * - * @method isFixedBuild - * @param build Build Object - * @param 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' }); - - if ((failureBuild && !successBuild) || failureBuild.id > successBuild.id) { - return true; - } - - return false; -} - -/** - * Stops a frozen build from executing - * @method stopFrozenBuild - * @param {Object} build Build Object - * @param {String} previousStatus Prevous 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 {Object} build Build Object - * @param {Object} app Hapi app Object - */ -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(); -} +const { getScmUri, getUserPermissions } = require('../helper'); +const { updateBuildAndTriggerDownstreamJobs } = require('./helper/updateBuild'); /** * Validate if build status can be updated @@ -127,86 +66,6 @@ async function validateUserPermission(build, request) { await getUserPermissions({ user, scmUri, level: 'push', isAdmin: adminDetails.isAdmin }); } -/** - * Set build status to desired status, set build statusMessage - * @param {Object} 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) { - // UNSTABLE -> SUCCESS needs to update meta and endtime. - // However, the status itself cannot be updated to SUCCESS - const currentStatus = build.status; - - if (currentStatus !== 'UNSTABLE') { - if (desiredStatus !== undefined) { - build.status = desiredStatus; - } - if (build.status === 'ABORTED') { - if (currentStatus === 'FROZEN') { - build.statusMessage = `Frozen build aborted by ${username}`; - } else { - build.statusMessage = `Aborted by ${username}`; - } - } else if (build.status === 'FAILURE' || build.status === 'SUCCESS') { - if (statusMessage) { - build.statusMessage = statusMessage; - build.statusMessageType = statusMessageType || null; - } - } else { - build.statusMessage = statusMessage || null; - build.statusMessageType = statusMessageType || null; - } - } -} - -/** - * 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); -} - -/** - * Checks if all builds in stage are done running - * @param {Object} stage Stage - * @param {Object} event Event - * @return {Boolean} Flag if stage is done - */ -async function isStageDone({ stage, event }) { - // Get all jobIds for jobs in the stage - const stageJobIds = stage.jobIds; - - stageJobIds.push(stage.setup); - - // Get all builds in a stage for this event - const stageJobBuilds = await event.getBuilds({ params: { jobId: stageJobIds } }); - let stageIsDone = false; - - if (stageJobBuilds && stageJobBuilds.length !== 0) { - stageIsDone = !stageJobBuilds.some(b => !FINISHED_STATUSES.includes(b.status)); - } - - return stageIsDone; -} - module.exports = () => ({ method: 'PUT', path: '/builds/{id}', @@ -220,13 +79,10 @@ module.exports = () => ({ }, handler: async (request, h) => { - const { buildFactory, eventFactory, jobFactory, stageFactory, stageBuildFactory } = request.server.app; + const { buildFactory } = request.server.app; const { id } = request.params; - const { statusMessage, statusMessageType, stats, status: desiredStatus } = request.payload; const { username, scmContext, scope } = request.auth.credentials; const isBuild = scope.includes('build') || scope.includes('temporal'); - const { triggerNextJobs, removeJoinBuilds, createOrUpdateStageTeardownBuild } = - request.server.plugins.builds; // Check token permissions if (isBuild && username !== id) { @@ -234,144 +90,18 @@ module.exports = () => ({ } const build = await getBuildToUpdate(id, buildFactory); - const currentStatus = build.status; if (!isBuild) { await validateUserPermission(build, request); } - 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 = request.payload.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, request.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 request.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/${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 }, - request.server.app - ); - - if (stage && !isStageTeardown) { - await createOrUpdateStageTeardownBuild( - { pipeline, job, build, username, scmContext, event, stage }, - request.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 }, request.server.app); - } else { - await triggerNextJobs( - { pipeline, job, build: newBuild, username, scmContext, event: newEvent }, - request.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 stageIsDone = await isStageDone({ stage, event: newEvent }); - - if (stageIsDone) { - stageTeardownBuild.status = 'QUEUED'; - await stageTeardownBuild.update(); - await stageTeardownBuild.start(); - } - } - } + const newBuild = await updateBuildAndTriggerDownstreamJobs( + request.payload, + build, + request.server, + username, + scmContext + ); return h.response(await newBuild.toJsonWithSteps()).code(200); }, diff --git a/plugins/events/create.js b/plugins/events/create.js index c98ed16b2..846b05867 100644 --- a/plugins/events/create.js +++ b/plugins/events/create.js @@ -5,6 +5,8 @@ const boom = require('@hapi/boom'); const validationSchema = require('screwdriver-data-schema'); const ANNOT_RESTRICT_PR = 'screwdriver.cd/restrictPR'; const { getScmUri, isStageTeardown } = require('../helper'); +const { updateBuildAndTriggerDownstreamJobs } = require('../builds/helper/updateBuild'); +const { Status, BUILD_STATUS_MESSAGES } = require('../builds/triggers/helpers'); module.exports = () => ({ method: 'POST', @@ -244,6 +246,23 @@ module.exports = () => ({ if (event.builds === null) { return boom.notFound('No jobs to start.'); } + + const virtualJobBuilds = event.builds.filter(b => b.status === 'CREATED'); + + for (const build of virtualJobBuilds) { + await updateBuildAndTriggerDownstreamJobs( + { + status: Status.SUCCESS, + statusMessage: BUILD_STATUS_MESSAGES.SKIP_VIRTUAL_JOB.statusMessage, + statusMessageType: BUILD_STATUS_MESSAGES.SKIP_VIRTUAL_JOB.statusMessageType + }, + build, + request.server, + username, + scmContext + ); + } + // everything succeeded, inform the user const location = urlLib.format({ host: request.headers.host, diff --git a/test/plugins/data/builds.json b/test/plugins/data/builds.json index 6518d9060..471fca90a 100644 --- a/test/plugins/data/builds.json +++ b/test/plugins/data/builds.json @@ -151,4 +151,41 @@ } ], "status": "QUEUED" +},{ + "id": 776677, + "jobId": 1234, + "number": 5, + "sha": "58393af682d61de87789fb4961645c42180cec5a", + "cause": "Started by user foo", + "createTime": "2038-01-19T03:17:08.131Z", + "startTime": "2038-01-19T03:18:08.131Z", + "endTime": "2038-01-19T03:19:10.131Z", + "parameters": {}, + "steps": [ + { + "name": "sd-setup", + "code": 0, + "startTime": "2038-01-19T03:15:08.131Z", + "endTime": "2038-01-19T03:15:08.532Z" + }, + { + "name": "install", + "code": 5, + "startTime": "2038-01-19T03:15:08.532Z", + "endTime": "2038-01-19T03:15:09.114Z" + }, + { + "name": "test" + }, + { + "name": "publish" + }, + { + "name": "sd-cleanup", + "code": 0, + "startTime": "2038-01-19T03:15:09.115Z", + "endTime": "2038-01-19T03:15:10.130Z" + } + ], + "status": "CREATED" }] diff --git a/test/plugins/data/events.json b/test/plugins/data/events.json index 5f5297469..e77be5fdf 100644 --- a/test/plugins/data/events.json +++ b/test/plugins/data/events.json @@ -19,7 +19,9 @@ "nodes": [ { "name": "~pr" }, { "name": "~commit" }, - { "name": "main" }, + { "name": "main", + "id": 1234 + }, { "name": "publish" }, { "name": "beta" } ], diff --git a/test/plugins/events.test.js b/test/plugins/events.test.js index 24114a6ed..0d64279f4 100644 --- a/test/plugins/events.test.js +++ b/test/plugins/events.test.js @@ -101,7 +101,8 @@ describe('event plugin test', () => { getFullDisplayName: sinon.stub().returns('Memys Elfandi') }; buildFactoryMock = { - get: sinon.stub() + get: sinon.stub(), + create: sinon.stub() }; jobFactoryMock = { get: sinon.stub() @@ -137,6 +138,7 @@ describe('event plugin test', () => { }) })); server.auth.strategy('token', 'custom'); + server.auth.strategy('session', 'custom'); await server.register([ { plugin: bannerMock }, @@ -144,6 +146,10 @@ describe('event plugin test', () => { { // eslint-disable-next-line global-require plugin: require('../../plugins/pipelines') + }, + { + // eslint-disable-next-line global-require + plugin: require('../../plugins/builds') } ]); }); @@ -292,6 +298,7 @@ describe('event plugin test', () => { let scmConfig; let userMock; let pipelineMock; + let eventMock; let meta; const username = 'myself'; const parentBuildId = 12345; @@ -335,8 +342,23 @@ describe('event plugin test', () => { chainPR: false, annotations: { 'screwdriver.cd/restrictPR': 'none' + }, + workflowGraph: { + nodes: [ + { name: '~pr' }, + { name: '~commit' }, + { name: 'main', id: 1234 }, + { name: 'publish' }, + { name: 'beta' } + ], + edges: [ + { src: '~commit', dest: 'main' }, + { src: 'main', dest: 'publish' }, + { src: 'publish', dest: 'beta' } + ] } }; + pipelineMock.toJson = sinon.stub().returns(pipelineMock); scmConfig = { prNum: null, scmContext: 'github:github.com', @@ -348,6 +370,7 @@ describe('event plugin test', () => { foo: 'bar', one: 1 }; + options = { method: 'POST', url: '/events', @@ -377,8 +400,12 @@ describe('event plugin test', () => { meta }; - eventFactoryMock.get.withArgs(parentEventId).resolves(getEventMock(testEvent)); - eventFactoryMock.create.resolves(getEventMock(testEvent)); + eventMock = getEventMock(testEvent); + // eventFactoryMock.get.withArgs(parentEventId).resolves(getEventMock(testEvent)); + eventFactoryMock.get.withArgs(eventMock.id).resolves(eventMock); + + eventMock.builds = []; + eventFactoryMock.create.resolves(eventMock); userFactoryMock.get.resolves(userMock); pipelineFactoryMock.get.resolves(pipelineMock); }); @@ -525,6 +552,87 @@ describe('event plugin test', () => { }); }); + it('returns 201 when it skips execution of virtual builds and trigger downstream builds', () => { + delete options.payload.parentBuildId; + delete eventConfig.parentBuildId; + + eventMock.builds = getBuildMocks(testBuilds); + eventMock.builds.forEach(b => { + b.eventId = eventMock.id; + }); + + const virtualBuildMock = eventMock.builds[4]; + + virtualBuildMock.status = 'CREATED'; + + const virtualJobMock = { + id: virtualBuildMock.jobId, + pipelineId, + name: 'main', + pipeline: pipelineMock, + permutations: [ + { + settings: { + email: 'foo@bar.com' + } + } + ], + getLatestBuild: sinon.stub().resolves(virtualBuildMock) + }; + + jobFactoryMock.get.withArgs(virtualJobMock.id).resolves(virtualJobMock); + + virtualBuildMock.job = virtualJobMock; + virtualBuildMock.update = sinon.stub().resolves(virtualBuildMock); + + eventMock.update = sinon.stub().resolves(eventMock); + + server.events = { + emit: sinon.stub().resolves(null) + }; + + jobFactoryMock.get.withArgs(virtualJobMock.id).resolves(virtualJobMock); + eventFactoryMock.get.withArgs({ id: eventMock.id }).resolves(eventMock); + + const jobPublishMock = { + id: 1235, + pipelineId, + state: 'ENABLED', + parsePRJobName: sinon.stub().returns('publish'), + permutations: [ + { + settings: { + email: 'foo@bar.com' + } + } + ] + }; + + jobFactoryMock.get.withArgs(jobPublishMock.id).resolves(jobPublishMock); + jobFactoryMock.get.withArgs({ pipelineId, name: 'publish' }).resolves(jobPublishMock); + buildFactoryMock.get.withArgs({ eventId: eventMock.id, jobId: jobPublishMock.id }).returns(null); + + return server.inject(options).then(reply => { + expectedLocation = { + host: reply.request.headers.host, + port: reply.request.headers.port, + protocol: reply.request.server.info.protocol, + pathname: `${options.url}/12345` + }; + assert.equal(reply.statusCode, 201); + assert.calledWith(userMock.getPermissions, scmUri, scmContext, scmRepo); + assert.calledWith(eventFactoryMock.create, eventConfig); + assert.strictEqual(reply.headers.location, urlLib.format(expectedLocation)); + assert.calledWith(eventFactoryMock.scm.getCommitSha, scmConfig); + assert.notCalled(eventFactoryMock.scm.getPrInfo); + + assert.equal(virtualBuildMock.status, 'SUCCESS'); + assert.calledOnce(virtualBuildMock.update); + assert.calledOnce(buildFactoryMock.create); + assert.calledWith(buildFactoryMock.create, sinon.match({ jobId: jobPublishMock.id })); + }); + }); + it('returns 201 when it successfully creates an event with parent event', () => { eventConfig.parentEventId = parentEventId; eventConfig.workflowGraph = getEventMock(testEvent).workflowGraph; From 409534ac7b77ac375e928f9b78324249a33241ea Mon Sep 17 00:00:00 2001 From: Dayanand Sagar Date: Mon, 16 Dec 2024 09:16:09 -0800 Subject: [PATCH 2/2] feat(3181): Addressing code review suggestions --- plugins/builds/helper/updateBuild.js | 90 +++++++++++++++------------- test/plugins/events.test.js | 1 - 2 files changed, 48 insertions(+), 43 deletions(-) diff --git a/plugins/builds/helper/updateBuild.js b/plugins/builds/helper/updateBuild.js index 67a5d4b64..bfefe7351 100644 --- a/plugins/builds/helper/updateBuild.js +++ b/plugins/builds/helper/updateBuild.js @@ -11,14 +11,15 @@ const FINISHED_STATUSES = ['FAILURE', 'SUCCESS', 'ABORTED', 'UNSTABLE', 'COLLAPS /** * @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 Object - * @param jobFactory Job Factory instance + * @param {Build} build Build Object + * @param {JobFactory} jobFactory Job Factory instance */ async function isFixedBuild(build, jobFactory) { if (build.status !== 'SUCCESS') { @@ -29,18 +30,15 @@ async function isFixedBuild(build, jobFactory) { const failureBuild = await job.getLatestBuild({ status: 'FAILURE' }); const successBuild = await job.getLatestBuild({ status: 'SUCCESS' }); - if ((failureBuild && !successBuild) || failureBuild.id > successBuild.id) { - return true; - } - - return false; + return !!((failureBuild && !successBuild) || failureBuild.id > successBuild.id); } /** * Stops a frozen build from executing + * * @method stopFrozenBuild - * @param {Object} build Build Object - * @param {String} previousStatus Prevous build status + * @param {Build} build Build Object + * @param {String} previousStatus Previous build status */ async function stopFrozenBuild(build, previousStatus) { if (previousStatus !== 'FROZEN') { @@ -52,9 +50,11 @@ async function stopFrozenBuild(build, previousStatus) { /** * Updates execution details for init step - * @method stopFrozenBuild - * @param {Object} build Build Object - * @param {Object} app Hapi app Object + * + * @method stopFrozenBuild + * @param {Build} build Build Object + * @param {Object} app Hapi app Object + * @returns {Promise} Updated step */ async function updateInitStep(build, app) { const step = await app.stepFactory.get({ buildId: build.id, name: 'sd-setup-init' }); @@ -72,46 +72,53 @@ async function updateInitStep(build, app) { /** * Set build status to desired status, set build statusMessage - * @param {Object} 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 + * + * @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 - const currentStatus = build.status; + if (currentStatus === 'UNSTABLE') { + return; + } - if (currentStatus !== 'UNSTABLE') { - if (desiredStatus !== undefined) { - build.status = desiredStatus; - } - if (build.status === 'ABORTED') { - if (currentStatus === 'FROZEN') { - build.statusMessage = `Frozen build aborted by ${username}`; - } else { - build.statusMessage = `Aborted by ${username}`; - } - } else if (build.status === 'FAILURE' || build.status === 'SUCCESS') { + 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; } - } else { + 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 + * + * @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); @@ -129,15 +136,14 @@ async function getStage({ stageFactory, workflowGraph, jobName, pipelineId }) { /** * Checks if all builds in stage are done running - * @param {Object} stage Stage - * @param {Object} event Event - * @return {Boolean} Flag if stage is done + * + * @param {Stage} stage Stage + * @param {Event} event Event + * @return {Boolean} Flag if stage is done */ async function isStageDone({ stage, event }) { // Get all jobIds for jobs in the stage - const stageJobIds = stage.jobIds; - - stageJobIds.push(stage.setup); + const stageJobIds = [...stage.jobIds, stage.setup]; // Get all builds in a stage for this event const stageJobBuilds = await event.getBuilds({ params: { jobId: stageJobIds } }); diff --git a/test/plugins/events.test.js b/test/plugins/events.test.js index 0d64279f4..365defacf 100644 --- a/test/plugins/events.test.js +++ b/test/plugins/events.test.js @@ -401,7 +401,6 @@ describe('event plugin test', () => { }; eventMock = getEventMock(testEvent); - // eventFactoryMock.get.withArgs(parentEventId).resolves(getEventMock(testEvent)); eventFactoryMock.get.withArgs(eventMock.id).resolves(eventMock); eventMock.builds = [];