diff --git a/packages/cli/jest.config.js b/packages/cli/jest.config.js index b01fd50..80544a3 100644 --- a/packages/cli/jest.config.js +++ b/packages/cli/jest.config.js @@ -2,12 +2,13 @@ module.exports = { collectCoverage: true, coverageThreshold: { global: { - branches: 30, - functions: 50, - lines: 55, - statements: 60, + branches: 34, + functions: 54, + lines: 59, + statements: 62, }, }, preset: 'ts-jest', setupFiles: ['/setupTests.js'], + silent: true, }; diff --git a/packages/cli/package.json b/packages/cli/package.json index 719d5b2..d4d403e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -33,12 +33,12 @@ "@octokit/webhooks": "^9.6.0", "@slack/webhook": "^6.0.0", "adm-zip": "^0.5.5", - "app-root-path": "^3.0.0", "aws-sdk": "^2.906.0", "builtin-modules": "^3.2.0", "change-case": "^4.1.2", "deep-equal": "^2.0.5", "deepmerge": "^4.2.2", + "dotenv": "^10.0.0", "esbuild": "^0.12.26", "find-up": "^5.0.0", "glob": "^7.1.7", @@ -56,12 +56,9 @@ }, "devDependencies": { "@types/adm-zip": "^0.4.34", - "@types/app-root-path": "^1.2.4", "@types/aws-lambda": "^8.10.76", "@types/deep-equal": "^1.0.1", - "@types/eslint": "^7.2.10", "@types/faker": "^5.5.5", - "@types/jest": "^26.0.23", "@types/js-yaml": "^4.0.1", "@types/mime-types": "^2.1.0", "@types/node": "^15.0.3", diff --git a/packages/cli/src/deploy/cicd/lambdas/githubWebhooksApiV1.handler.spec.ts b/packages/cli/src/deploy/cicd/lambdas/githubWebhooksApiV1.handler.spec.ts index 69f8a5a..1d3a36a 100644 --- a/packages/cli/src/deploy/cicd/lambdas/githubWebhooksApiV1.handler.spec.ts +++ b/packages/cli/src/deploy/cicd/lambdas/githubWebhooksApiV1.handler.spec.ts @@ -1,16 +1,5 @@ /* eslint-disable import/first */ -// const webhooksOnErrorMock = jest.fn(); - -// const webhooksReceiveMock = jest.fn(); - -// jest.mock('@octokit/webhooks', () => ({ -// Webhooks: jest.fn().mockReturnValue({ -// onError: webhooksOnErrorMock, -// receive: webhooksReceiveMock, -// }), -// })); - const putObjectMock = jest.fn().mockReturnValue({ promise: jest.fn() }); jest.mock('aws-sdk', () => ({ @@ -20,6 +9,13 @@ jest.mock('aws-sdk', () => ({ }), })); +const executeTasksMock = jest.fn(); + +jest.mock('./executeTasks', () => ({ + executeTasks: executeTasksMock, + shConditionalCommands: jest.fn(), +})); + import { githubWebhooksApiV1Handler, webhooks, @@ -44,12 +40,78 @@ beforeEach(() => { delete process.env.PIPELINES_JSON; delete process.env.TRIGGER_PIPELINES_OBJECT_KEY_PREFIX; delete process.env.BASE_STACK_BUCKET_NAME; +}); - webhooksReceiveMock.mockClear(); +afterEach(() => { + jest.clearAllMocks(); }); -test('should call S3 putObject', async () => { - process.env.PIPELINES_JSON = JSON.stringify(['main']); +test.each([['opened'], ['reopened'], ['synchronize']])( + 'should execute pr handler with action %s', + async (action) => { + process.env.PIPELINES_JSON = JSON.stringify(['main', 'pr']); + + const response = await handler({ + headers: { + 'X-GitHub-Delivery': xGitHubDelivery, + 'X-GitHub-Event': 'pull_request', + 'X-Hub-Signature-256': xHubSignature, + }, + body: JSON.stringify({ + action, + number: 1, + pull_request: { + draft: false, + head: {}, + }, + }), + }); + + expect(executeTasksMock).toHaveBeenCalledTimes(1); + + expect(response).toMatchObject({ body: '{"ok":true}', statusCode: 200 }); + }, +); + +test('should execute main handler only one time per webhook', async () => { + process.env.PIPELINES_JSON = JSON.stringify(['main', 'pr']); + process.env.TRIGGER_PIPELINES_OBJECT_KEY_PREFIX = 'some/prefix'; + process.env.BASE_STACK_BUCKET_NAME = 'base-stack'; + + /** + * Execute first to add the main and pr listeners. + */ + await handler({ + headers: { + 'X-GitHub-Delivery': xGitHubDelivery, + 'X-GitHub-Event': 'push', + 'X-Hub-Signature-256': xHubSignature, + }, + body: JSON.stringify({}), + }); + + await handler({ + headers: { + 'X-GitHub-Delivery': xGitHubDelivery, + 'X-GitHub-Event': 'push', + 'X-Hub-Signature-256': xHubSignature, + }, + body: JSON.stringify({ ref: 'refs/heads/main' }), + }); + + expect(putObjectMock).toHaveBeenCalledWith( + expect.objectContaining({ + Body: expect.any(Buffer), + Bucket: 'base-stack', + Key: 'some/prefix/main.zip', + }), + ); + + expect(putObjectMock).toHaveBeenCalledTimes(1); +}); + +test('should call S3 putObject for tag', async () => { + process.env.PIPELINES_JSON = JSON.stringify(['tag', 'main']); process.env.TRIGGER_PIPELINES_OBJECT_KEY_PREFIX = 'some/prefix'; process.env.BASE_STACK_BUCKET_NAME = 'base-stack'; @@ -72,6 +134,37 @@ test('should call S3 putObject', async () => { }), ); + expect(putObjectMock).toHaveBeenCalledTimes(1); + + expect(response).toMatchObject({ body: '{"ok":true}', statusCode: 200 }); +}); + +test('should call S3 putObject for main', async () => { + process.env.PIPELINES_JSON = JSON.stringify(['tag', 'main']); + process.env.TRIGGER_PIPELINES_OBJECT_KEY_PREFIX = 'some/prefix'; + process.env.BASE_STACK_BUCKET_NAME = 'base-stack'; + + const body = { ref: 'refs/tags/' }; + + const response: any = await handler({ + headers: { + 'X-GitHub-Delivery': xGitHubDelivery, + 'X-GitHub-Event': 'push', + 'X-Hub-Signature-256': xHubSignature, + }, + body: JSON.stringify(body), + }); + + expect(putObjectMock).toHaveBeenCalledWith( + expect.objectContaining({ + Body: expect.any(Buffer), + Bucket: 'base-stack', + Key: 'some/prefix/tag.zip', + }), + ); + + expect(putObjectMock).toHaveBeenCalledTimes(1); + expect(response).toMatchObject({ body: '{"ok":true}', statusCode: 200 }); }); diff --git a/packages/cli/src/deploy/cicd/lambdas/githubWebhooksApiV1.handler.ts b/packages/cli/src/deploy/cicd/lambdas/githubWebhooksApiV1.handler.ts index 29d90c5..362958d 100644 --- a/packages/cli/src/deploy/cicd/lambdas/githubWebhooksApiV1.handler.ts +++ b/packages/cli/src/deploy/cicd/lambdas/githubWebhooksApiV1.handler.ts @@ -11,11 +11,6 @@ import { getProcessEnvVariable } from './getProcessEnvVariable'; const s3 = new S3(); -/** - * Put outside of the handler to be able to spy on it. - */ -export const webhooks = new Webhooks({ secret: '123' }); - /** * When this file is saved on S3, a CodePipeline pipeline is started. */ @@ -43,6 +38,99 @@ const putJobDetails = async ({ .promise(); }; +/** + * Put outside of the handler to be able to spy on it. + */ +export const webhooks = new Webhooks({ secret: '123' }); + +const getPipelines = () => { + const pipelines: Pipeline[] = JSON.parse( + process.env.PIPELINES_JSON || JSON.stringify([]), + ); + + return pipelines; +}; + +webhooks.on('push', async (details) => { + if (!getPipelines().includes('tag')) { + return; + } + + if (details.payload.ref.startsWith('refs/tags/')) { + await putJobDetails({ pipeline: 'tag', details }); + } +}); + +webhooks.on('push', async (details) => { + if (!getPipelines().includes('main')) { + return; + } + + if (details.payload.ref === 'refs/heads/main') { + await putJobDetails({ pipeline: 'main', details }); + } +}); + +webhooks.on( + ['pull_request.opened', 'pull_request.reopened', 'pull_request.synchronize'], + async ({ payload }) => { + if (!getPipelines().includes('pr')) { + return; + } + + if (payload.pull_request.draft) { + return; + } + + await executeTasks({ + commands: [ + shConditionalCommands({ + conditionalCommands: getPrCommands({ + branch: payload.pull_request.head.ref, + }), + }), + ], + tags: [ + { key: 'Pipeline', value: 'pr' }, + { key: 'PullRequest', value: payload.number.toString() }, + { key: 'PullRequestTitle', value: payload.pull_request.title }, + { key: 'PullRequestUrl', value: payload.pull_request.url }, + { key: 'Action', value: payload.action }, + { key: 'Branch', value: payload.pull_request.head.ref }, + ], + }); + }, +); + +webhooks.on(['pull_request.closed'], async ({ payload }) => { + if (!getPipelines().includes('closed-pr')) { + return; + } + + /** + * https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-cpu-memory-error.html + */ + await executeTasks({ + cpu: '256', + memory: '512', + commands: [ + shConditionalCommands({ + conditionalCommands: getClosedPrCommands({ + branch: payload.pull_request.head.ref, + }), + }), + ], + tags: [ + { key: 'Pipeline', value: 'pr' }, + { key: 'PullRequest', value: payload.number.toString() }, + { key: 'PullRequestTitle', value: payload.pull_request.title }, + { key: 'PullRequestUrl', value: payload.pull_request.url }, + { key: 'Action', value: payload.action }, + { key: 'Branch', value: payload.pull_request.head.ref }, + ], + }); +}); + export const githubWebhooksApiV1Handler: ProxyHandler = async ( event, context, @@ -76,87 +164,6 @@ export const githubWebhooksApiV1Handler: ProxyHandler = async ( throw new Error("X-Hub-Signature-256 or X-Hub-Signature doesn't exist."); } - const pipelines: Pipeline[] = JSON.parse( - process.env.PIPELINES_JSON || JSON.stringify([]), - ); - - if (pipelines.includes('pr')) { - webhooks.on( - [ - 'pull_request.opened', - 'pull_request.reopened', - 'pull_request.ready_for_review', - 'pull_request.synchronize', - ], - async ({ payload }) => { - if (payload.pull_request.draft) { - return; - } - - await executeTasks({ - commands: [ - shConditionalCommands({ - conditionalCommands: getPrCommands({ - branch: payload.pull_request.head.ref, - }), - }), - ], - tags: [ - { key: 'Pipeline', value: 'pr' }, - { key: 'PullRequest', value: payload.number.toString() }, - { key: 'PullRequestTitle', value: payload.pull_request.title }, - { key: 'PullRequestUrl', value: payload.pull_request.url }, - { key: 'Action', value: payload.action }, - { key: 'Branch', value: payload.pull_request.head.ref }, - ], - }); - }, - ); - } - - if (pipelines.includes('closed-pr')) { - webhooks.on(['pull_request.closed'], async ({ payload }) => { - /** - * https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-cpu-memory-error.html - */ - await executeTasks({ - cpu: '256', - memory: '512', - commands: [ - shConditionalCommands({ - conditionalCommands: getClosedPrCommands({ - branch: payload.pull_request.head.ref, - }), - }), - ], - tags: [ - { key: 'Pipeline', value: 'pr' }, - { key: 'PullRequest', value: payload.number.toString() }, - { key: 'PullRequestTitle', value: payload.pull_request.title }, - { key: 'PullRequestUrl', value: payload.pull_request.url }, - { key: 'Action', value: payload.action }, - { key: 'Branch', value: payload.pull_request.head.ref }, - ], - }); - }); - } - - if (pipelines.includes('main')) { - webhooks.on('push', async (details) => { - if (details.payload.ref === 'refs/heads/main') { - await putJobDetails({ pipeline: 'main', details }); - } - }); - } - - if (pipelines.includes('tag')) { - webhooks.on('push', async (details) => { - if (details.payload.ref.startsWith('refs/tags/')) { - await putJobDetails({ pipeline: 'tag', details }); - } - }); - } - webhooks.onError((onErrorEvent) => { throw onErrorEvent; }); diff --git a/packages/cli/src/deploy/cloudFormation.core.ts b/packages/cli/src/deploy/cloudFormation.core.ts index 5ac7dcf..4e29ddf 100644 --- a/packages/cli/src/deploy/cloudFormation.core.ts +++ b/packages/cli/src/deploy/cloudFormation.core.ts @@ -11,12 +11,15 @@ import { UpdateTerminationProtectionCommand, } from '@aws-sdk/client-cloudformation'; import AWS from 'aws-sdk'; +import * as fs from 'fs'; import log from 'npmlog'; +import * as path from 'path'; import { CloudFormationTemplate, getEnvironment, getEnvVar } from '../utils'; import { addDefaults } from './addDefaults.cloudFormation'; import { emptyS3Directory } from './s3'; +import { getStackName } from './stackName'; const logPrefix = 'cloudformation'; log.addLevel('event', 10000, { fg: 'yellow' }); @@ -89,12 +92,12 @@ export const doesStackExist = async ({ stackName }: { stackName: string }) => { await describeStacks({ stackName }); log.info(logPrefix, `Stack ${stackName} already exists.`); return true; - } catch (err) { - if (err.Code === 'ValidationError') { + } catch (error: any) { + if (error.Code === 'ValidationError') { log.info(logPrefix, `Stack ${stackName} does not exist.`); return false; } - throw err; + throw error; } }; @@ -152,6 +155,26 @@ export const getStackOutput = async ({ return output; }; +const saveEnvironmentOutput = async ({ + outputs, +}: { + outputs: AWS.CloudFormation.Output[]; +}) => { + const stackName = await getStackName(); + + const envFile: any = { stackName, outputs }; + + const dotCarlinFolderPath = path.join(process.cwd(), '.carlin'); + + if (!fs.existsSync(dotCarlinFolderPath)) { + await fs.promises.mkdir(dotCarlinFolderPath); + } + + const filePath = path.join(dotCarlinFolderPath, `${stackName}.json`); + + await fs.promises.writeFile(filePath, JSON.stringify(envFile, null, 2)); +}; + export const printStackOutputsAfterDeploy = async ({ stackName, }: { @@ -160,27 +183,27 @@ export const printStackOutputsAfterDeploy = async ({ const { EnableTerminationProtection, StackName, - Outputs, + Outputs = [], } = await describeStack({ stackName }); + await saveEnvironmentOutput({ outputs: Outputs }); + log.output('Describe Stack'); log.output('StackName', StackName); log.output('EnableTerminationProtection', EnableTerminationProtection); - (Outputs || []).forEach( - ({ OutputKey, OutputValue, Description, ExportName }) => { - log.output( - `${OutputKey}`, - [ - '', - `OutputKey: ${OutputKey}`, - `OutputValue: ${OutputValue}`, - `Description: ${Description}`, - `ExportName: ${ExportName}`, - '', - ].join('\n'), - ); - }, - ); + Outputs.forEach(({ OutputKey, OutputValue, Description, ExportName }) => { + log.output( + `${OutputKey}`, + [ + '', + `OutputKey: ${OutputKey}`, + `OutputValue: ${OutputValue}`, + `Description: ${Description}`, + `ExportName: ${ExportName}`, + '', + ].join('\n'), + ); + }); }; export const deleteStack = async ({ stackName }: { stackName: string }) => { @@ -231,14 +254,14 @@ export const updateStack = async ({ await cloudFormationV2() .waitFor('stackUpdateComplete', { StackName: stackName }) .promise(); - } catch (err) { - if (err.message === 'No updates are to be performed.') { - log.info(logPrefix, err.message); + } catch (error: any) { + if (error.message === 'No updates are to be performed.') { + log.info(logPrefix, error.message); return; } log.error(logPrefix, 'An error occurred when updating stack.'); await describeStackEvents({ stackName }); - throw err; + throw error; } log.info(logPrefix, `Stack ${stackName} was updated.`); }; diff --git a/packages/cli/src/deploy/cloudFormation.ts b/packages/cli/src/deploy/cloudFormation.ts index 4d14cfe..a40897b 100644 --- a/packages/cli/src/deploy/cloudFormation.ts +++ b/packages/cli/src/deploy/cloudFormation.ts @@ -195,7 +195,7 @@ export const deployCloudFormation = async ({ }); return output; - } catch (error) { + } catch (error: any) { return handleDeployError({ error, logPrefix }); } }; @@ -270,7 +270,7 @@ export const destroyCloudFormation = async ({ const stackName = defaultStackName || (await getStackName()); log.info(logPrefix, `stackName: ${stackName}`); await destroy({ stackName }); - } catch (error) { + } catch (error: any) { handleDeployError({ error, logPrefix }); } }; diff --git a/packages/cli/src/utils/buildLambdaFiles.ts b/packages/cli/src/utils/buildLambdaFiles.ts deleted file mode 100644 index 671926f..0000000 --- a/packages/cli/src/utils/buildLambdaFiles.ts +++ /dev/null @@ -1,100 +0,0 @@ -import builtins from 'builtin-modules'; -import * as fs from 'fs'; -import * as path from 'path'; -import webpack from 'webpack'; - -/** - * Using Webpack because of issue #8. - * {@link https://github.com/ttoss/carlin/issues/8} - */ -export const buildLambdaSingleFile = async ({ - lambdaExternals, - lambdaInput, - output, -}: { - lambdaExternals: string[]; - lambdaInput: string; - output?: webpack.Configuration['output']; -}) => { - const webpackConfig: webpack.Configuration = { - entry: path.join(process.cwd(), lambdaInput), - mode: 'none', - externals: ['aws-sdk', ...builtins, ...lambdaExternals], - module: { - rules: [ - { - exclude: /node_modules/, - test: /\.tsx?$/, - loader: require.resolve('ts-loader'), - options: { - compilerOptions: { - /** - * Packages like 'serverless-http' cannot be used without this - * property. - */ - allowSyntheticDefaultImports: true, - esModuleInterop: true, - declaration: false, - target: 'es2017', - /** - * Fix https://stackoverflow.com/questions/65202242/how-to-use-rollup-js-to-create-a-common-js-and-named-export-bundle/65202822#65202822 - */ - module: 'esnext', - noEmit: false, - }, - }, - }, - ], - }, - resolve: { - extensions: ['.tsx', '.ts', '.js', '.json'], - }, - target: 'node', - output, - }; - - const compiler = webpack(webpackConfig); - - return new Promise((resolve, reject) => { - compiler.run((err, result) => { - if (err) { - return reject(err); - } - - return resolve(result); - }); - }); -}; - -export const getBuiltLambdaFile = async ({ - dirname, - relativePath, - lambdaExternals = [], -}: { - dirname: string; - relativePath: string; - lambdaExternals?: string[]; -}) => { - const getPath = (extension: 'js' | 'ts') => - path.resolve(dirname, `${relativePath}.${extension}`); - - const lambdaInput = await (async () => { - if (fs.existsSync(getPath('js'))) { - return fs.promises.readFile(getPath('js'), 'utf-8'); - } - - if (fs.existsSync(getPath('ts'))) { - return fs.promises.readFile(getPath('ts'), 'utf-8'); - } - - throw new Error(`File ${relativePath} doesn't exist.`); - })(); - - const stats = await buildLambdaSingleFile({ lambdaExternals, lambdaInput }); - - if (!stats) { - throw new Error(`getBuiltFile cannot build ${relativePath}`); - } - - return stats.toJson(); -}; diff --git a/yarn.lock b/yarn.lock index 88cc91b..b0064a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7281,6 +7281,11 @@ dot-prop@^6.0.1: dependencies: is-obj "^2.0.0" +dotenv@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" + integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== + download@^6.2.2: version "6.2.5" resolved "https://registry.yarnpkg.com/download/-/download-6.2.5.tgz#acd6a542e4cd0bb42ca70cfc98c9e43b07039714"