Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] Gérer le déploiement des review app sur scalingo #104

Merged
merged 3 commits into from
Apr 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions build/controllers/github.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const ScalingoClient = require('../../common/services/scalingo-client');

const repositoryToScalingoAppsReview = {
pix: ['pix-front-review', 'pix-api-review'],
'pix-editor': ['pix-lcms-review'],
'pix-db-replication': ['pix-datawarehouse-integration'],
'pix-db-stats': ['pix-db-stats-review'],
'pix-site': ['pix-site-review'],
'pix-bot': ['pix-bot-review']
};

module.exports = {
async processWebhook(request) {
const eventName = request.headers['x-github-event'];
if (eventName === 'pull_request') {
const payload = request.payload;
const repository = payload.pull_request.head.repo.name;
const prId = payload.number;
const reviewApps = repositoryToScalingoAppsReview[repository];
if (payload.pull_request.head.repo.fork) {
return 'No RA for a fork';
}
if (payload.action !== 'opened') {
return `Ignoring ${payload.action} action`;
}
if (!reviewApps) {
return 'No RA configured for this repository';
}
const client = await ScalingoClient.getInstance('reviewApps');
for (const appName of reviewApps) {
await client.deployReviewApp(appName, prId);
}
return `Created RA on app ${reviewApps.join(', ')} with pr ${prId}`;
} else {
return `Ignoring ${eventName} event`;
}
}
};
11 changes: 11 additions & 0 deletions build/routes/github.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const { githubConfig } = require('../../common/config');
const githubController = require('../../build/controllers/github');

module.exports = [
{
method: 'POST',
path: '/github/webhook',
handler: githubController.processWebhook,
config: githubConfig
},
];
14 changes: 12 additions & 2 deletions common/config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const { verifySignatureAndParseBody } = require('./services/slack/security');
const { verifySignatureAndParseBody: verifySlackSignatureAndParseBody } = require('./services/slack/security');
const { verifyWebhookSignature: verifyGithubSignature } = require('./services/github');

module.exports = {
slackConfig: {
Expand All @@ -7,7 +8,16 @@ module.exports = {
parse: false
},
pre: [
{method: verifySignatureAndParseBody, assign: 'payload'}
{ method: verifySlackSignatureAndParseBody, assign: 'payload' }
]
},

githubConfig: {
payload: {
allow: ['application/json'],
},
pre: [
{ method: verifyGithubSignature }
]
}
};
34 changes: 32 additions & 2 deletions common/services/github.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
const { Octokit } = require('@octokit/rest');
const settings = require('../../config');
const { zipWith, countBy, entries, noop } = require('lodash');
const crypto = require('crypto');
const tsscmp = require('tsscmp');
const Boom = require('@hapi/boom');
const settings = require('../../config');

const color = {
'team-evaluation': '#FDEEC1',
Expand Down Expand Up @@ -209,6 +212,19 @@ async function _getCommitsWhereConfigFileHasChangedSinceDate(repoOwner, repoName
return data;
}

function _verifyRequestSignature(webhookSecret, body, signature) {
if (!signature) {
throw Boom.unauthorized('Github signature is empty.');
}
const [, hash] = signature.split('=');
const hmac = crypto.createHmac('sha256', webhookSecret);
hmac.update(body);

if (!tsscmp(hash, hmac.digest('hex'))) {
throw Boom.unauthorized('Github signature verification failed. Signature mismatch.');
}
}

module.exports = {

async getPullRequests(label) {
Expand Down Expand Up @@ -271,6 +287,20 @@ module.exports = {
const latestReleaseDate = await _getLatestReleaseDate(repoOwner, repoName);
const commits = await _getCommitsWhereConfigFileHasChangedSinceDate(repoOwner, repoName, latestReleaseDate);
return commits.length > 0;
}
},

verifyWebhookSignature(request) {
const { headers, payload } = request;

const webhookSecret = settings.github.webhookSecret;
const signature = headers['x-hub-signature-256'];
const stringBody = payload ? JSON.stringify(payload) : '';

try {
_verifyRequestSignature(webhookSecret, stringBody, signature);
} catch (error) {
return error;
}
return true;
}
};
4 changes: 4 additions & 0 deletions common/services/scalingo-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ class ScalingoClient {
return [await this._getSingleAppInfo(target)];
}

deployReviewApp(appName, prId) {
return this.client.SCMRepoLinks.manualReviewApp(appName, prId);
}

async _getAllAppsInfo(environment) {
const apps = ['api', 'app', 'orga', 'certif', 'admin'];
const promises = apps.map(appName => this._getSingleAppInfo(appName, environment));
Expand Down
2 changes: 2 additions & 0 deletions config.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ module.exports = (function() {
token: process.env.GITHUB_PERSONAL_ACCESS_TOKEN,
owner: process.env.GITHUB_OWNER,
repository: process.env.GITHUB_REPOSITORY,
webhookSecret: process.env.GITHUB_WEBHOOK_SECRET,
},

googleSheet: {
Expand Down Expand Up @@ -110,6 +111,7 @@ module.exports = (function() {

config.github.owner = 'github-owner';
config.github.repository = 'github-repository';
config.github.webhookSecret = 'github-webhook-secret';

config.slack.requestSigningSecret = 'slack-super-signing-secret';

Expand Down
126 changes: 126 additions & 0 deletions test/acceptance/build/github_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
const { expect } = require('chai');
const { createGithubWebhookSignatureHeader, nock } = require('../../test-helper');
const server = require('../../../server');

describe('Acceptance | Build | Github', function() {
describe('POST /github/webhook', function() {
let body;

beforeEach(() => {
body = {
action: 'opened',
number: 2,
pull_request: {
head: {
repo: {
name: 'pix',
fork: false
}
}
}
};
});

it('responds with 200 and create the RA on scalingo', async () => {
const scalingoAuth = nock('https://auth.scalingo.com')
.post('/v1/tokens/exchange')
.reply(201);
const scalingoDeploy1 = nock('https://api.osc-fr1.scalingo.com')
.post('/v1/apps/pix-front-review/scm_repo_link/manual_review_app', {pull_request_id: 2})
.reply(201);
const scalingoDeploy2 = nock('https://api.osc-fr1.scalingo.com')
.post('/v1/apps/pix-api-review/scm_repo_link/manual_review_app', {pull_request_id: 2})
.reply(201);

const res = await server.inject({
method: 'POST',
url: '/github/webhook',
headers: {
...createGithubWebhookSignatureHeader(JSON.stringify(body)),
'x-github-event': 'pull_request'
},
payload: body,
});
expect(res.statusCode).to.equal(200);
expect(res.result).to.eql('Created RA on app pix-front-review, pix-api-review with pr 2');
expect(scalingoAuth.isDone()).to.be.true;
expect(scalingoDeploy1.isDone()).to.be.true;
expect(scalingoDeploy2.isDone()).to.be.true;
});

it('responds with 200 and doesn\'t create the RA on scalingo when the PR is from a fork', async () => {
body.pull_request.head.repo.fork = true;

const res = await server.inject({
method: 'POST',
url: '/github/webhook',
headers: {
...createGithubWebhookSignatureHeader(JSON.stringify(body)),
'x-github-event': 'pull_request'
},
payload: body,
});
expect(res.statusCode).to.equal(200);
expect(res.result).to.eql('No RA for a fork');
});

it('responds with 200 and doesn\'t create the RA on scalingo when the PR is not from a configured repo', async () => {
body.pull_request.head.repo.name = 'pix-repository-that-dont-exist';

const res = await server.inject({
method: 'POST',
url: '/github/webhook',
headers: {
...createGithubWebhookSignatureHeader(JSON.stringify(body)),
'x-github-event': 'pull_request'
},
payload: body,
});
expect(res.statusCode).to.equal(200);
expect(res.result).to.eql('No RA configured for this repository');
});

it('responds with 200 and do nothing for other action on pull request', async () => {
body.action = 'edited';

const res = await server.inject({
method: 'POST',
url: '/github/webhook',
headers: {
...createGithubWebhookSignatureHeader(JSON.stringify(body)),
'x-github-event': 'pull_request'
},
payload: body,
});
expect(res.statusCode).to.equal(200);
expect(res.result).to.eql('Ignoring edited action');
});

it('responds with 200 and do nothing for other event', async () => {
const res = await server.inject({
method: 'POST',
url: '/github/webhook',
headers: {
...createGithubWebhookSignatureHeader(JSON.stringify(body)),
'x-github-event': 'deployment'
},
payload: body,
});
expect(res.statusCode).to.equal(200);
expect(res.result).to.eql('Ignoring deployment event');
});

it('responds with 401', async () => {
const res = await server.inject({
method: 'POST',
url: '/github/webhook',
headers: {
'x-hub-signature-256': 'sha256=test',
'x-github-event': 'pull_request'
},
payload: body,
});
expect(res.statusCode).to.equal(401);
});
});
});
13 changes: 13 additions & 0 deletions test/test-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ const chai = require('chai');
const { expect } = chai;
const sinon = require('sinon');
const nock = require('nock');
const crypto = require('crypto');
const config = require('../config');

chai.use(require('sinon-chai'));

Expand All @@ -11,6 +13,7 @@ beforeEach(() => {

afterEach(function () {
sinon.restore();
nock.cleanAll();
});

function catchErr(promiseFn, ctx) {
Expand All @@ -24,9 +27,19 @@ function catchErr(promiseFn, ctx) {
};
}

function createGithubWebhookSignatureHeader(body) {
const hmac = crypto.createHmac('sha256', config.github.webhookSecret);
hmac.update(body);

return {
'x-hub-signature-256': 'sha256='+ hmac.digest('hex'),
};
}

module.exports = {
catchErr,
expect,
nock,
sinon,
createGithubWebhookSignatureHeader,
};
Loading