Skip to content

Commit

Permalink
feat: adds a debounce for RA deployments
Browse files Browse the repository at this point in the history
  • Loading branch information
nlepage committed Jan 28, 2025
1 parent f72ff40 commit 7040f36
Show file tree
Hide file tree
Showing 11 changed files with 541 additions and 7 deletions.
22 changes: 20 additions & 2 deletions build/controllers/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -69,6 +70,7 @@ async function _handleRA(
addMessageToPullRequest = _addMessageToPullRequest,
githubService = commonGithubService,
reviewAppRepository = reviewAppRepo,
reviewAppDeploymentRepository = reviewAppDeploymentRepo,
) {
const payload = request.payload;
const prId = payload.number;
Expand All @@ -90,6 +92,7 @@ async function _handleRA(
addMessageToPullRequest,
githubService,
reviewAppRepository,
reviewAppDeploymentRepository,
);

return `Triggered deployment of RA on app ${deployedRA.join(', ')} with pr ${prId}`;
Expand Down Expand Up @@ -153,6 +156,7 @@ async function deployPullRequest(
addMessageToPullRequest,
githubService,
reviewAppRepository,
reviewAppDeploymentRepository,
) {
const deployedRA = [];
let client;
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions build/repositories/review-app-deployment-repository.js
Original file line number Diff line number Diff line change
@@ -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<void>}
*/
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<void>}
*/
export async function remove(appName, knexConn = knex) {
await knexConn.delete().from(TABLE_NAME).where('appName', appName);
}

/**
* @param {Knex} knexConn
* @returns {Promise<Deployment[]>}
*/
export async function listForDeployment(knexConn = knex) {
const deployments = await knexConn
.select()
.from(TABLE_NAME)
.where('after', '<', knexConn.raw('NOW()'))
.orderBy('after')
.forUpdate();
return deployments;
}
72 changes: 72 additions & 0 deletions build/services/review-app-deployment.js
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
});
}
10 changes: 5 additions & 5 deletions common/services/scalingo-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const TABLE_NAME = 'review-apps-deployments';

/**
* @param {import("knex").Knex} knex
* @returns {Promise<void>}
*/
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<void>}
*/
export async function down(knex) {
await knex.schema.dropTable(TABLE_NAME);
}
17 changes: 17 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -13,6 +14,7 @@ import server from './server.js';

const init = async () => {
await ecoModeService.start();
await reviewAppDeployment.start();

createCronJob(
'Deploy Pix site',
Expand All @@ -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();
7 changes: 7 additions & 0 deletions sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions test/acceptance/build/github_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
Loading

0 comments on commit 7040f36

Please sign in to comment.