From 7040f364d76591e0622c6a564f149a1ba55639a1 Mon Sep 17 00:00:00 2001 From: Nicolas Lepage <19571875+nlepage@users.noreply.github.com> Date: Tue, 19 Nov 2024 16:30:43 +0100 Subject: [PATCH] feat: adds a debounce for RA deployments --- build/controllers/github.js | 22 +- .../review-app-deployment-repository.js | 44 +++ build/services/review-app-deployment.js | 72 +++++ common/services/scalingo-client.js | 10 +- config.js | 1 + ...37_create-table-review-apps-deployments.js | 21 ++ index.js | 17 ++ sample.env | 7 + test/acceptance/build/github_test.js | 1 + .../review-app-deployment-repository_test.js | 259 ++++++++++++++++++ .../services/review-app-deployment_test.js | 94 +++++++ 11 files changed, 541 insertions(+), 7 deletions(-) create mode 100644 build/repositories/review-app-deployment-repository.js create mode 100644 build/services/review-app-deployment.js create mode 100644 db/migrations/20250114140337_create-table-review-apps-deployments.js create mode 100644 test/integration/build/repositories/review-app-deployment-repository_test.js create mode 100644 test/unit/build/services/review-app-deployment_test.js diff --git a/build/controllers/github.js b/build/controllers/github.js index f559e434..9cb91f73 100644 --- a/build/controllers/github.js +++ b/build/controllers/github.js @@ -7,6 +7,7 @@ import { logger } from '../../common/services/logger.js'; import ScalingoClient from '../../common/services/scalingo-client.js'; import { config } from '../../config.js'; import * as reviewAppRepo from '../repositories/review-app-repository.js'; +import * as reviewAppDeploymentRepo from '../repositories/review-app-deployment-repository.js'; import { MERGE_STATUS, mergeQueue as _mergeQueue } from '../services/merge-queue.js'; const repositoryToScalingoAppsReview = { @@ -69,6 +70,7 @@ async function _handleRA( addMessageToPullRequest = _addMessageToPullRequest, githubService = commonGithubService, reviewAppRepository = reviewAppRepo, + reviewAppDeploymentRepository = reviewAppDeploymentRepo, ) { const payload = request.payload; const prId = payload.number; @@ -90,6 +92,7 @@ async function _handleRA( addMessageToPullRequest, githubService, reviewAppRepository, + reviewAppDeploymentRepository, ); return `Triggered deployment of RA on app ${deployedRA.join(', ')} with pr ${prId}`; @@ -153,6 +156,7 @@ async function deployPullRequest( addMessageToPullRequest, githubService, reviewAppRepository, + reviewAppDeploymentRepository, ) { const deployedRA = []; let client; @@ -166,12 +170,20 @@ async function deployPullRequest( try { const reviewAppExists = await client.reviewAppExists(reviewAppName); if (reviewAppExists) { - await client.deployUsingSCM(reviewAppName, ref); + await reviewAppDeploymentRepository.save({ + appName: reviewAppName, + scmRef: ref, + after: getDeployAfter(), + }); } else { await reviewAppRepository.create({ name: reviewAppName, repository, prNumber: prId, parentApp: appName }); await client.deployReviewApp(appName, prId); await client.disableAutoDeploy(reviewAppName); - await client.deployUsingSCM(reviewAppName, ref); + await reviewAppDeploymentRepository.save({ + appName: reviewAppName, + scmRef: ref, + after: getDeployAfter(), + }); } deployedRA.push({ name: appName, isCreated: !reviewAppExists }); } catch (error) { @@ -341,6 +353,12 @@ function _handleNoRACase(request) { return { shouldContinue: true }; } +function getDeployAfter() { + const now = new Date(); + const deployAfter = new Date(now.getTime() + config.scalingo.reviewApps.deployDebounce); + return deployAfter; +} + export { _addMessageToPullRequest as addMessageToPullRequest, getMessage, diff --git a/build/repositories/review-app-deployment-repository.js b/build/repositories/review-app-deployment-repository.js new file mode 100644 index 00000000..ac0e488e --- /dev/null +++ b/build/repositories/review-app-deployment-repository.js @@ -0,0 +1,44 @@ +import { knex } from '../../db/knex-database-connection.js'; + +const TABLE_NAME = 'review-apps-deployments'; + +/** + * @typedef {{ + * appName: string + * scmRef: string + * after: Date + * }} Deployment + * + * @typedef {import('knex').Knex} Knex + */ + +/** + * @param {Deployment} deployment + * @returns {Promise} + */ +export async function save({ appName, scmRef, after }) { + await knex.insert({ appName, scmRef, after }).into(TABLE_NAME).onConflict('appName').merge(); +} + +/** + * @param {string} appName + * @param {Knex} knexConn + * @returns {Promise} + */ +export async function remove(appName, knexConn = knex) { + await knexConn.delete().from(TABLE_NAME).where('appName', appName); +} + +/** + * @param {Knex} knexConn + * @returns {Promise} + */ +export async function listForDeployment(knexConn = knex) { + const deployments = await knexConn + .select() + .from(TABLE_NAME) + .where('after', '<', knexConn.raw('NOW()')) + .orderBy('after') + .forUpdate(); + return deployments; +} diff --git a/build/services/review-app-deployment.js b/build/services/review-app-deployment.js new file mode 100644 index 00000000..39c06b38 --- /dev/null +++ b/build/services/review-app-deployment.js @@ -0,0 +1,72 @@ +import { setTimeout } from 'node:timers/promises'; + +import * as reviewAppDeploymentRepository from '../repositories/review-app-deployment-repository.js'; +import ScalingoClient from '../../common/services/scalingo-client.js'; +import { logger } from '../../common/services/logger.js'; +import { config } from '../../config.js'; +import { knex } from '../../db/knex-database-connection.js'; + +let started = false; +let stopped; + +export async function start() { + let scalingoClient; + + try { + scalingoClient = await ScalingoClient.getInstance('reviewApps'); + } catch (error) { + throw new Error(`Scalingo auth APIError: ${error.message}`); + } + + const delay = config.scalingo.reviewApps.deployDebounce / 5; + + started = true; + stopped = Promise.withResolvers(); + + (async () => { + while (started) { + const [result] = await Promise.allSettled([ + deploy({ reviewAppDeploymentRepository, scalingoClient }), + Promise.race(setTimeout(delay), stopped.promise), + ]); + + if (result.status === 'rejected') { + logger.error({ + message: 'an error occured while deploying review apps', + data: result.reason, + }); + } + } + })(); +} + +export function stop() { + started = false; + stopped.resolve(); +} + +export async function deploy({ reviewAppDeploymentRepository, scalingoClient }) { + await knex.transaction(async (transaction) => { + const deployments = await reviewAppDeploymentRepository.listForDeployment(transaction); + + for (const deployment of deployments) { + if (!started) break; + + try { + const reviewAppStillExists = await scalingoClient.reviewAppExists(deployment.appName); + + if (reviewAppStillExists) { + await scalingoClient.deployUsingSCM(deployment.appName, deployment.scmRef); + } + + await reviewAppDeploymentRepository.remove(deployment.appName, transaction); + } catch (err) { + logger.error({ + event: 'scalingo', + message: 'error while trying to deploy review app', + data: err, + }); + } + } + }); +} diff --git a/common/services/scalingo-client.js b/common/services/scalingo-client.js index 102d37b3..208c0f8b 100644 --- a/common/services/scalingo-client.js +++ b/common/services/scalingo-client.js @@ -48,16 +48,16 @@ class ScalingoClient { return `${scalingoApp} ${releaseTag} has been deployed`; } - async deployUsingSCM(scalingoApp, releaseTag) { + async deployUsingSCM(scalingoApp, scmRef) { try { - await this.client.SCMRepoLinks.manualDeploy(scalingoApp, releaseTag); + await this.client.SCMRepoLinks.manualDeploy(scalingoApp, scmRef); } catch (e) { logger.error({ event: 'scalingo', message: e }); - throw new Error(`Unable to deploy ${scalingoApp} ${releaseTag}`); + throw new Error(`Unable to deploy ${scalingoApp} ${scmRef}`); } - logger.info({ message: `Deployment of ${scalingoApp} ${releaseTag} has been requested` }); - return `Deployment of ${scalingoApp} ${releaseTag} has been requested`; + logger.info({ message: `Deployment of ${scalingoApp} ${scmRef} has been requested` }); + return `Deployment of ${scalingoApp} ${scmRef} has been requested`; } async getAppInfo(target) { diff --git a/config.js b/config.js index 696a566f..d6ff27a3 100644 --- a/config.js +++ b/config.js @@ -47,6 +47,7 @@ const configuration = (function () { reviewApps: { token: process.env.SCALINGO_TOKEN_REVIEW_APPS, apiUrl: process.env.SCALINGO_API_URL_REVIEW_APPS || 'https://api.osc-fr1.scalingo.com', + deployDebounce: _getNumber(process.env.REVIEW_APP_DEPLOY_DEBOUNCE, 30) * 1000, }, integration: { token: process.env.SCALINGO_TOKEN_INTEGRATION, diff --git a/db/migrations/20250114140337_create-table-review-apps-deployments.js b/db/migrations/20250114140337_create-table-review-apps-deployments.js new file mode 100644 index 00000000..504dfd38 --- /dev/null +++ b/db/migrations/20250114140337_create-table-review-apps-deployments.js @@ -0,0 +1,21 @@ +const TABLE_NAME = 'review-apps-deployments'; + +/** + * @param {import("knex").Knex} knex + * @returns {Promise} + */ +export async function up(knex) { + await knex.schema.createTable(TABLE_NAME, (table) => { + table.string('appName', 255).notNullable().primary(); + table.string('scmRef', 255).notNullable(); + table.datetime('after').notNullable(); + }); +} + +/** + * @param {import("knex").Knex} knex + * @returns {Promise} + */ +export async function down(knex) { + await knex.schema.dropTable(TABLE_NAME); +} diff --git a/index.js b/index.js index 97bdbd4e..7a72e54f 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ import * as dotenv from 'dotenv'; dotenv.config(); import ecoModeService from './build/services/eco-mode-service.js'; +import * as reviewAppDeployment from './build/services/review-app-deployment.js'; import { createCronJob } from './common/services/cron-job.js'; import github from './common/services/github.js'; import { logger } from './common/services/logger.js'; @@ -13,6 +14,7 @@ import server from './server.js'; const init = async () => { await ecoModeService.start(); + await reviewAppDeployment.start(); createCronJob( 'Deploy Pix site', @@ -32,6 +34,21 @@ const init = async () => { event: 'main', message: `Server running on "${server.info.uri}"`, }); + + process.on('SIGTERM', () => { + exitOnSignal('SIGTERM'); + }); + process.on('SIGINT', () => { + exitOnSignal('SIGINT'); + }); }; +function exitOnSignal() { + logger.info({ + event: 'main', + message: `Received signal: ${signal}.`, + }); + reviewAppDeployment.stop(); +} + init(); diff --git a/sample.env b/sample.env index 2feccae9..372594bf 100644 --- a/sample.env +++ b/sample.env @@ -340,6 +340,13 @@ SCALINGO_TOKEN_REVIEW_APPS=__CHANGE_ME__ # default: "0 0 8 * * 1-5" #REVIEW_APP_START_SCHEDULE=0 0 8 * * 1-5 +# Debounce time for deploying review apps. +# +# presence: optionnal +# type: number +# default: 30 +#REVIEW_APP_DEPLOY_DEBOUNCE=30 + # List of review apps that must not be managed. # # If not present, all the review apps will be stopped and restrated. diff --git a/test/acceptance/build/github_test.js b/test/acceptance/build/github_test.js index 5652c06e..e2b63ec5 100644 --- a/test/acceptance/build/github_test.js +++ b/test/acceptance/build/github_test.js @@ -7,6 +7,7 @@ describe('Acceptance | Build | Github', function () { beforeEach(async function () { await knex('review-apps').truncate(); await knex('pull_requests').truncate(); + await knex('review-apps-deployments').truncate(); }); describe('POST /github/webhook', function () { diff --git a/test/integration/build/repositories/review-app-deployment-repository_test.js b/test/integration/build/repositories/review-app-deployment-repository_test.js new file mode 100644 index 00000000..6beef0bb --- /dev/null +++ b/test/integration/build/repositories/review-app-deployment-repository_test.js @@ -0,0 +1,259 @@ +import { describe } from 'mocha'; +import { expect } from '../../../test-helper.js'; +import * as reviewAppDeploymentRepository from '../../../../build/repositories/review-app-deployment-repository.js'; +import { knex } from '../../../../db/knex-database-connection.js'; + +const TABLE_NAME = 'review-apps-deployments'; + +describe('Integration | Build | Repository | Review App Deployment', function () { + afterEach(async function () { + await knex(TABLE_NAME).truncate(); + }); + + describe('#save', function () { + it('should insert a deployment', async function () { + // given + const deployment = { + appName: 'ma-super-appli', + scmRef: 'ma-vieille-branche', + after: new Date('2025-01-14T14:29:14.416Z'), + }; + + // when + await reviewAppDeploymentRepository.save(deployment); + + // then + const reviewAppDeployments = await knex.select().from(TABLE_NAME); + + expect(reviewAppDeployments).to.deep.equal([ + { + appName: 'ma-super-appli', + scmRef: 'ma-vieille-branche', + after: new Date('2025-01-14T14:29:14.416Z'), + }, + ]); + }); + + describe('when deployments exist for other apps', () => { + it('should insert a deployment', async function () { + // given + await knex + .insert({ + appName: 'l-appli-de-mon-cousin', + scmRef: 'un-rameau', + after: new Date('2015-01-14T14:29:14.416Z'), + }) + .into(TABLE_NAME); + await knex + .insert({ + appName: 'l-appli-de-ma-grand-mere', + scmRef: 'une-souche', + after: new Date('1925-01-14T14:29:14.416Z'), + }) + .into(TABLE_NAME); + + const deployment = { + appName: 'ma-super-appli', + scmRef: 'ma-vieille-branche', + after: new Date('2025-01-14T14:29:14.416Z'), + }; + + // when + await reviewAppDeploymentRepository.save(deployment); + + // then + const reviewAppDeployments = await knex.select().from(TABLE_NAME).orderBy('after'); + + expect(reviewAppDeployments).to.deep.equal([ + { + appName: 'l-appli-de-ma-grand-mere', + scmRef: 'une-souche', + after: new Date('1925-01-14T14:29:14.416Z'), + }, + { + appName: 'l-appli-de-mon-cousin', + scmRef: 'un-rameau', + after: new Date('2015-01-14T14:29:14.416Z'), + }, + { + appName: 'ma-super-appli', + scmRef: 'ma-vieille-branche', + after: new Date('2025-01-14T14:29:14.416Z'), + }, + ]); + }); + }); + + describe('when a deployment already exists for the app', () => { + it('should update the deployment', async function () { + // given + await knex + .insert({ + appName: 'ma-super-appli', + scmRef: 'ma-vieille-branche', + after: new Date('2024-12-31T14:29:14.416Z'), + }) + .into(TABLE_NAME); + + const deployment = { + appName: 'ma-super-appli', + scmRef: 'ma-nouvelle-branche', + after: new Date('2025-01-14T14:29:14.416Z'), + }; + + // when + await reviewAppDeploymentRepository.save(deployment); + + // then + const reviewAppDeployments = await knex.select().from(TABLE_NAME); + + expect(reviewAppDeployments).to.deep.equal([ + { + appName: 'ma-super-appli', + scmRef: 'ma-nouvelle-branche', + after: new Date('2025-01-14T14:29:14.416Z'), + }, + ]); + }); + }); + }); + + describe('#remove', function () { + it('should remove deployment by appName', async function () { + // given + await knex + .insert({ + appName: 'l-appli-de-mon-cousin', + scmRef: 'un-rameau', + after: new Date('2015-01-14T14:29:14.416Z'), + }) + .into(TABLE_NAME); + await knex + .insert({ + appName: 'l-appli-de-ma-grand-mere', + scmRef: 'une-souche', + after: new Date('1925-01-14T14:29:14.416Z'), + }) + .into(TABLE_NAME); + + // when + await reviewAppDeploymentRepository.remove('l-appli-de-mon-cousin'); + + // then + const reviewAppDeployments = await knex.select().from(TABLE_NAME); + + expect(reviewAppDeployments).to.deep.equal([ + { + appName: 'l-appli-de-ma-grand-mere', + scmRef: 'une-souche', + after: new Date('1925-01-14T14:29:14.416Z'), + }, + ]); + }); + }); + + describe('#listForDeployment', () => { + let readyTime1, readyTime2, notReadyTime; + + beforeEach(async function () { + const now = new Date(); + readyTime1 = new Date(now.getTime() - 2000); + readyTime2 = new Date(now.getTime() - 1000); + notReadyTime = new Date(now.getTime() + 1000); + + await knex + .insert({ + appName: 'l-appli-de-mon-cousin', + scmRef: 'un-rameau', + after: notReadyTime, + }) + .into(TABLE_NAME); + await knex + .insert({ + appName: 'l-appli-de-ma-grand-mere', + scmRef: 'une-souche', + after: readyTime2, + }) + .into(TABLE_NAME); + await knex + .insert({ + appName: 'ma-super-appli', + scmRef: 'ma-nouvelle-branche', + after: readyTime1, + }) + .into(TABLE_NAME); + }); + + it('should list ready deployments', async function () { + // when + const result = await reviewAppDeploymentRepository.listForDeployment(); + + // then + expect(result).to.deep.equal([ + { + appName: 'ma-super-appli', + scmRef: 'ma-nouvelle-branche', + after: readyTime1, + }, + { + appName: 'l-appli-de-ma-grand-mere', + scmRef: 'une-souche', + after: readyTime2, + }, + ]); + }); + }); + + describe('when in a transaction', () => { + it('should lock when listing for deployment', async function () { + // given + const now = new Date(); + const readyTime = new Date(now.getTime() - 1000); + + await knex + .insert({ + appName: 'l-appli-de-ma-grand-mere', + scmRef: 'une-souche', + after: readyTime, + }) + .into(TABLE_NAME); + await knex + .insert({ + appName: 'ma-super-appli', + scmRef: 'ma-nouvelle-branche', + after: readyTime, + }) + .into(TABLE_NAME); + + const { promise: locked, resolve: resolveLocked } = Promise.withResolvers(); + + // when + const [updates] = await Promise.all([ + locked.then(() => + Promise.all([ + knex(TABLE_NAME).update({ scmRef: 'branche-d-accord' }).where('appName', 'ma-super-appli'), + knex(TABLE_NAME).update({ scmRef: 'branche-d-accord' }).where('appName', 'l-appli-de-ma-grand-mere'), + ]), + ), + knex.transaction(async (transaction) => { + await reviewAppDeploymentRepository.listForDeployment(transaction); + resolveLocked(); + await reviewAppDeploymentRepository.remove('ma-super-appli', transaction); + }), + ]); + + // then + expect(updates).to.deep.equal([0, 1]); + + const reviewAppDeployments = await knex.select().from(TABLE_NAME); + + expect(reviewAppDeployments).to.deep.equal([ + { + appName: 'l-appli-de-ma-grand-mere', + scmRef: 'branche-d-accord', + after: readyTime, + }, + ]); + }); + }); +}); diff --git a/test/unit/build/services/review-app-deployment_test.js b/test/unit/build/services/review-app-deployment_test.js new file mode 100644 index 00000000..7bf6533e --- /dev/null +++ b/test/unit/build/services/review-app-deployment_test.js @@ -0,0 +1,94 @@ +import { expect, sinon } from '../../../test-helper.js'; +import * as reviewAppDeployment from '../../../../build/services/review-app-deployment.js'; +import { knex } from '../../../../db/knex-database-connection.js'; + +describe('Unit | Build | Service | review-app-deployment', function () { + describe('#deploy', function () { + const transaction = Symbol('transaction'); + let reviewAppDeploymentRepository, scalingoClient; + + beforeEach(function () { + sinon.stub(knex, 'transaction').callsFake(async (callback) => callback(transaction)); + + reviewAppDeploymentRepository = { + listForDeployment: sinon.stub(), + remove: sinon.stub(), + }; + + scalingoClient = { + reviewAppExists: sinon.stub(), + deployUsingSCM: sinon.stub(), + }; + }); + + it('should send ready deployments to Scalingo', async function () { + // given + const deployments = [ + { appName: 'ma-super-appli', scmRef: 'un-rameau' }, + { appName: 'l-appli-de-ma-meme', scmRef: 'une-souche' }, + ]; + reviewAppDeploymentRepository.listForDeployment.withArgs(transaction).resolves(deployments); + scalingoClient.reviewAppExists.withArgs('ma-super-appli').resolves(true); + scalingoClient.reviewAppExists.withArgs('l-appli-de-ma-meme').resolves(true); + scalingoClient.deployUsingSCM.resolves(); + reviewAppDeploymentRepository.remove.resolves(); + + // when + await reviewAppDeployment.deploy({ reviewAppDeploymentRepository, scalingoClient }); + + // then + expect(scalingoClient.deployUsingSCM).to.have.been.calledWithExactly('ma-super-appli', 'un-rameau'); + expect(scalingoClient.deployUsingSCM).to.have.been.calledWithExactly('l-appli-de-ma-meme', 'une-souche'); + expect(reviewAppDeploymentRepository.remove).to.have.been.calledWithExactly('ma-super-appli', transaction); + expect(reviewAppDeploymentRepository.remove).to.have.been.calledWithExactly('l-appli-de-ma-meme', transaction); + }); + + describe('when an app doesn’t exist anymore', function () { + it('should skip deployment', async function () { + // given + const deployments = [ + { appName: 'ma-super-appli', scmRef: 'un-rameau' }, + { appName: 'l-appli-de-ma-meme', scmRef: 'une-souche' }, + ]; + reviewAppDeploymentRepository.listForDeployment.withArgs(transaction).resolves(deployments); + scalingoClient.reviewAppExists.withArgs('ma-super-appli').resolves(true); + scalingoClient.reviewAppExists.withArgs('l-appli-de-ma-meme').resolves(false); + scalingoClient.deployUsingSCM.resolves(); + reviewAppDeploymentRepository.remove.resolves(); + + // when + await reviewAppDeployment.deploy({ reviewAppDeploymentRepository, scalingoClient }); + + // then + expect(scalingoClient.deployUsingSCM).to.have.been.calledWithExactly('ma-super-appli', 'un-rameau'); + expect(scalingoClient.deployUsingSCM).not.to.have.been.calledWithExactly('l-appli-de-ma-meme', 'une-souche'); + expect(reviewAppDeploymentRepository.remove).to.have.been.calledWithExactly('ma-super-appli', transaction); + expect(reviewAppDeploymentRepository.remove).to.have.been.calledWithExactly('l-appli-de-ma-meme', transaction); + }); + }); + + describe('when an error occurs for a deployment', function () { + it('should perform other deployments', async function () { + // given + const deployments = [ + { appName: 'ma-super-appli', scmRef: 'un-rameau' }, + { appName: 'l-appli-de-ma-meme', scmRef: 'une-souche' }, + ]; + reviewAppDeploymentRepository.listForDeployment.withArgs(transaction).resolves(deployments); + scalingoClient.reviewAppExists.withArgs('ma-super-appli').resolves(true); + scalingoClient.reviewAppExists.withArgs('l-appli-de-ma-meme').resolves(true); + scalingoClient.deployUsingSCM.resolves(); + scalingoClient.deployUsingSCM.withArgs('ma-super-appli', 'un-rameau').rejects(); + reviewAppDeploymentRepository.remove.resolves(); + + // when + await reviewAppDeployment.deploy({ reviewAppDeploymentRepository, scalingoClient }); + + // then + expect(scalingoClient.deployUsingSCM).to.have.been.calledWithExactly('ma-super-appli', 'un-rameau'); + expect(scalingoClient.deployUsingSCM).to.have.been.calledWithExactly('l-appli-de-ma-meme', 'une-souche'); + expect(reviewAppDeploymentRepository.remove).to.have.been.calledWithExactly('l-appli-de-ma-meme', transaction); + }); + }); + }); +});