diff --git a/.circleci/config.yml b/.circleci/config.yml index b0610449790..6752d72d5b2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1211,6 +1211,14 @@ jobs: environment: TEST_SUITE: src/__tests__/iam-permissions-boundary.test.ts CLI_REGION: ap-southeast-1 + hooks-amplify_e2e_tests: + working_directory: ~/repo + docker: *ref_1 + resource_class: large + steps: *ref_5 + environment: + TEST_SUITE: src/__tests__/hooks.test.ts + CLI_REGION: ap-southeast-2 function_7-amplify_e2e_tests: working_directory: ~/repo docker: *ref_1 @@ -1218,7 +1226,7 @@ jobs: steps: *ref_5 environment: TEST_SUITE: src/__tests__/function_7.test.ts - CLI_REGION: ap-southeast-2 + CLI_REGION: us-east-2 function_6-amplify_e2e_tests: working_directory: ~/repo docker: *ref_1 @@ -1226,7 +1234,7 @@ jobs: steps: *ref_5 environment: TEST_SUITE: src/__tests__/function_6.test.ts - CLI_REGION: us-east-2 + CLI_REGION: us-west-2 function_5-amplify_e2e_tests: working_directory: ~/repo docker: *ref_1 @@ -1234,7 +1242,7 @@ jobs: steps: *ref_5 environment: TEST_SUITE: src/__tests__/function_5.test.ts - CLI_REGION: us-west-2 + CLI_REGION: eu-west-2 frontend_config_drift-amplify_e2e_tests: working_directory: ~/repo docker: *ref_1 @@ -1242,7 +1250,7 @@ jobs: steps: *ref_5 environment: TEST_SUITE: src/__tests__/frontend_config_drift.test.ts - CLI_REGION: eu-west-2 + CLI_REGION: eu-central-1 container-hosting-amplify_e2e_tests: working_directory: ~/repo docker: *ref_1 @@ -1250,7 +1258,7 @@ jobs: steps: *ref_5 environment: TEST_SUITE: src/__tests__/container-hosting.test.ts - CLI_REGION: eu-central-1 + CLI_REGION: ap-northeast-1 configure-project-amplify_e2e_tests: working_directory: ~/repo docker: *ref_1 @@ -1258,7 +1266,7 @@ jobs: steps: *ref_5 environment: TEST_SUITE: src/__tests__/configure-project.test.ts - CLI_REGION: ap-northeast-1 + CLI_REGION: ap-southeast-1 auth_6-amplify_e2e_tests: working_directory: ~/repo docker: *ref_1 @@ -1266,7 +1274,7 @@ jobs: steps: *ref_5 environment: TEST_SUITE: src/__tests__/auth_6.test.ts - CLI_REGION: ap-southeast-1 + CLI_REGION: ap-southeast-2 api_4-amplify_e2e_tests: working_directory: ~/repo docker: *ref_1 @@ -1274,7 +1282,7 @@ jobs: steps: *ref_5 environment: TEST_SUITE: src/__tests__/api_4.test.ts - CLI_REGION: ap-southeast-2 + CLI_REGION: us-east-2 schema-iterative-update-4-amplify_e2e_tests_pkg_linux: working_directory: ~/repo docker: *ref_1 @@ -1965,6 +1973,16 @@ jobs: TEST_SUITE: src/__tests__/iam-permissions-boundary.test.ts CLI_REGION: ap-southeast-1 steps: *ref_6 + hooks-amplify_e2e_tests_pkg_linux: + working_directory: ~/repo + docker: *ref_1 + resource_class: large + environment: + AMPLIFY_DIR: /home/circleci/repo/out + AMPLIFY_PATH: /home/circleci/repo/out/amplify-pkg-linux + TEST_SUITE: src/__tests__/hooks.test.ts + CLI_REGION: ap-southeast-2 + steps: *ref_6 function_7-amplify_e2e_tests_pkg_linux: working_directory: ~/repo docker: *ref_1 @@ -1973,7 +1991,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/out AMPLIFY_PATH: /home/circleci/repo/out/amplify-pkg-linux TEST_SUITE: src/__tests__/function_7.test.ts - CLI_REGION: ap-southeast-2 + CLI_REGION: us-east-2 steps: *ref_6 function_6-amplify_e2e_tests_pkg_linux: working_directory: ~/repo @@ -1983,7 +2001,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/out AMPLIFY_PATH: /home/circleci/repo/out/amplify-pkg-linux TEST_SUITE: src/__tests__/function_6.test.ts - CLI_REGION: us-east-2 + CLI_REGION: us-west-2 steps: *ref_6 function_5-amplify_e2e_tests_pkg_linux: working_directory: ~/repo @@ -1993,7 +2011,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/out AMPLIFY_PATH: /home/circleci/repo/out/amplify-pkg-linux TEST_SUITE: src/__tests__/function_5.test.ts - CLI_REGION: us-west-2 + CLI_REGION: eu-west-2 steps: *ref_6 frontend_config_drift-amplify_e2e_tests_pkg_linux: working_directory: ~/repo @@ -2003,7 +2021,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/out AMPLIFY_PATH: /home/circleci/repo/out/amplify-pkg-linux TEST_SUITE: src/__tests__/frontend_config_drift.test.ts - CLI_REGION: eu-west-2 + CLI_REGION: eu-central-1 steps: *ref_6 container-hosting-amplify_e2e_tests_pkg_linux: working_directory: ~/repo @@ -2013,7 +2031,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/out AMPLIFY_PATH: /home/circleci/repo/out/amplify-pkg-linux TEST_SUITE: src/__tests__/container-hosting.test.ts - CLI_REGION: eu-central-1 + CLI_REGION: ap-northeast-1 steps: *ref_6 configure-project-amplify_e2e_tests_pkg_linux: working_directory: ~/repo @@ -2023,7 +2041,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/out AMPLIFY_PATH: /home/circleci/repo/out/amplify-pkg-linux TEST_SUITE: src/__tests__/configure-project.test.ts - CLI_REGION: ap-northeast-1 + CLI_REGION: ap-southeast-1 steps: *ref_6 auth_6-amplify_e2e_tests_pkg_linux: working_directory: ~/repo @@ -2033,7 +2051,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/out AMPLIFY_PATH: /home/circleci/repo/out/amplify-pkg-linux TEST_SUITE: src/__tests__/auth_6.test.ts - CLI_REGION: ap-southeast-1 + CLI_REGION: ap-southeast-2 steps: *ref_6 api_4-amplify_e2e_tests_pkg_linux: working_directory: ~/repo @@ -2043,7 +2061,7 @@ jobs: AMPLIFY_DIR: /home/circleci/repo/out AMPLIFY_PATH: /home/circleci/repo/out/amplify-pkg-linux TEST_SUITE: src/__tests__/api_4.test.ts - CLI_REGION: ap-southeast-2 + CLI_REGION: us-east-2 steps: *ref_6 workflows: version: 2 @@ -2147,64 +2165,64 @@ workflows: - /run-e2e\/*./ - done_with_node_e2e_tests: requires: - - analytics-amplify_e2e_tests - notifications-amplify_e2e_tests - schema-iterative-update-locking-amplify_e2e_tests - - function_6-amplify_e2e_tests + - function_7-amplify_e2e_tests + - api_4-amplify_e2e_tests - hosting-amplify_e2e_tests - tags-amplify_e2e_tests - s3-sse-amplify_e2e_tests - - function_5-amplify_e2e_tests + - function_6-amplify_e2e_tests - amplify-app-amplify_e2e_tests - init-amplify_e2e_tests - pull-amplify_e2e_tests - - frontend_config_drift-amplify_e2e_tests + - function_5-amplify_e2e_tests - schema-predictions-amplify_e2e_tests - amplify-configure-amplify_e2e_tests - migration-node-function-amplify_e2e_tests - - container-hosting-amplify_e2e_tests + - frontend_config_drift-amplify_e2e_tests - interactions-amplify_e2e_tests - datastore-modelgen-amplify_e2e_tests - layer-2-amplify_e2e_tests - - configure-project-amplify_e2e_tests + - container-hosting-amplify_e2e_tests - schema-data-access-patterns-amplify_e2e_tests - init-special-case-amplify_e2e_tests - iam-permissions-boundary-amplify_e2e_tests - - auth_6-amplify_e2e_tests + - configure-project-amplify_e2e_tests - schema-versioned-amplify_e2e_tests - plugin-amplify_e2e_tests - - function_7-amplify_e2e_tests - - api_4-amplify_e2e_tests + - hooks-amplify_e2e_tests + - auth_6-amplify_e2e_tests - done_with_pkg_linux_e2e_tests: requires: - - analytics-amplify_e2e_tests_pkg_linux - notifications-amplify_e2e_tests_pkg_linux - schema-iterative-update-locking-amplify_e2e_tests_pkg_linux - - function_6-amplify_e2e_tests_pkg_linux + - function_7-amplify_e2e_tests_pkg_linux + - api_4-amplify_e2e_tests_pkg_linux - hosting-amplify_e2e_tests_pkg_linux - tags-amplify_e2e_tests_pkg_linux - s3-sse-amplify_e2e_tests_pkg_linux - - function_5-amplify_e2e_tests_pkg_linux + - function_6-amplify_e2e_tests_pkg_linux - amplify-app-amplify_e2e_tests_pkg_linux - init-amplify_e2e_tests_pkg_linux - pull-amplify_e2e_tests_pkg_linux - - frontend_config_drift-amplify_e2e_tests_pkg_linux + - function_5-amplify_e2e_tests_pkg_linux - schema-predictions-amplify_e2e_tests_pkg_linux - amplify-configure-amplify_e2e_tests_pkg_linux - migration-node-function-amplify_e2e_tests_pkg_linux - - container-hosting-amplify_e2e_tests_pkg_linux + - frontend_config_drift-amplify_e2e_tests_pkg_linux - interactions-amplify_e2e_tests_pkg_linux - datastore-modelgen-amplify_e2e_tests_pkg_linux - layer-2-amplify_e2e_tests_pkg_linux - - configure-project-amplify_e2e_tests_pkg_linux + - container-hosting-amplify_e2e_tests_pkg_linux - schema-data-access-patterns-amplify_e2e_tests_pkg_linux - init-special-case-amplify_e2e_tests_pkg_linux - iam-permissions-boundary-amplify_e2e_tests_pkg_linux - - auth_6-amplify_e2e_tests_pkg_linux + - configure-project-amplify_e2e_tests_pkg_linux - schema-versioned-amplify_e2e_tests_pkg_linux - plugin-amplify_e2e_tests_pkg_linux - - function_7-amplify_e2e_tests_pkg_linux - - api_4-amplify_e2e_tests_pkg_linux + - hooks-amplify_e2e_tests_pkg_linux + - auth_6-amplify_e2e_tests_pkg_linux - amplify_migration_tests_latest: context: - amplify-ecr-image-pull @@ -2420,12 +2438,18 @@ workflows: filters: *ref_10 requires: - function_2-amplify_e2e_tests - - function_6-amplify_e2e_tests: + - function_7-amplify_e2e_tests: context: *ref_8 post-steps: *ref_9 filters: *ref_10 requires: - schema-key-amplify_e2e_tests + - api_4-amplify_e2e_tests: + context: *ref_8 + post-steps: *ref_9 + filters: *ref_10 + requires: + - analytics-amplify_e2e_tests - api_2-amplify_e2e_tests: context: *ref_8 post-steps: *ref_9 @@ -2486,7 +2510,7 @@ workflows: filters: *ref_10 requires: - delete-amplify_e2e_tests - - function_5-amplify_e2e_tests: + - function_6-amplify_e2e_tests: context: *ref_8 post-steps: *ref_9 filters: *ref_10 @@ -2552,7 +2576,7 @@ workflows: filters: *ref_10 requires: - schema-auth-3-amplify_e2e_tests - - frontend_config_drift-amplify_e2e_tests: + - function_5-amplify_e2e_tests: context: *ref_8 post-steps: *ref_9 filters: *ref_10 @@ -2618,7 +2642,7 @@ workflows: filters: *ref_10 requires: - schema-iterative-update-1-amplify_e2e_tests - - container-hosting-amplify_e2e_tests: + - frontend_config_drift-amplify_e2e_tests: context: *ref_8 post-steps: *ref_9 filters: *ref_10 @@ -2684,7 +2708,7 @@ workflows: filters: *ref_10 requires: - function_3-amplify_e2e_tests - - configure-project-amplify_e2e_tests: + - container-hosting-amplify_e2e_tests: context: *ref_8 post-steps: *ref_9 filters: *ref_10 @@ -2750,7 +2774,7 @@ workflows: filters: *ref_10 requires: - auth_5-amplify_e2e_tests - - auth_6-amplify_e2e_tests: + - configure-project-amplify_e2e_tests: context: *ref_8 post-steps: *ref_9 filters: *ref_10 @@ -2810,13 +2834,13 @@ workflows: filters: *ref_10 requires: - auth_3-amplify_e2e_tests - - function_7-amplify_e2e_tests: + - hooks-amplify_e2e_tests: context: *ref_8 post-steps: *ref_9 filters: *ref_10 requires: - auth_1-amplify_e2e_tests - - api_4-amplify_e2e_tests: + - auth_6-amplify_e2e_tests: context: *ref_8 post-steps: *ref_9 filters: *ref_10 @@ -2896,12 +2920,18 @@ workflows: filters: *ref_13 requires: - function_2-amplify_e2e_tests_pkg_linux - - function_6-amplify_e2e_tests_pkg_linux: + - function_7-amplify_e2e_tests_pkg_linux: context: *ref_11 post-steps: *ref_12 filters: *ref_13 requires: - schema-key-amplify_e2e_tests_pkg_linux + - api_4-amplify_e2e_tests_pkg_linux: + context: *ref_11 + post-steps: *ref_12 + filters: *ref_13 + requires: + - analytics-amplify_e2e_tests_pkg_linux - api_2-amplify_e2e_tests_pkg_linux: context: *ref_11 post-steps: *ref_12 @@ -2966,7 +2996,7 @@ workflows: filters: *ref_13 requires: - delete-amplify_e2e_tests_pkg_linux - - function_5-amplify_e2e_tests_pkg_linux: + - function_6-amplify_e2e_tests_pkg_linux: context: *ref_11 post-steps: *ref_12 filters: *ref_13 @@ -3036,7 +3066,7 @@ workflows: filters: *ref_13 requires: - schema-auth-3-amplify_e2e_tests_pkg_linux - - frontend_config_drift-amplify_e2e_tests_pkg_linux: + - function_5-amplify_e2e_tests_pkg_linux: context: *ref_11 post-steps: *ref_12 filters: *ref_13 @@ -3106,7 +3136,7 @@ workflows: filters: *ref_13 requires: - schema-iterative-update-1-amplify_e2e_tests_pkg_linux - - container-hosting-amplify_e2e_tests_pkg_linux: + - frontend_config_drift-amplify_e2e_tests_pkg_linux: context: *ref_11 post-steps: *ref_12 filters: *ref_13 @@ -3176,7 +3206,7 @@ workflows: filters: *ref_13 requires: - function_3-amplify_e2e_tests_pkg_linux - - configure-project-amplify_e2e_tests_pkg_linux: + - container-hosting-amplify_e2e_tests_pkg_linux: context: *ref_11 post-steps: *ref_12 filters: *ref_13 @@ -3246,7 +3276,7 @@ workflows: filters: *ref_13 requires: - auth_5-amplify_e2e_tests_pkg_linux - - auth_6-amplify_e2e_tests_pkg_linux: + - configure-project-amplify_e2e_tests_pkg_linux: context: *ref_11 post-steps: *ref_12 filters: *ref_13 @@ -3310,13 +3340,13 @@ workflows: filters: *ref_13 requires: - auth_3-amplify_e2e_tests_pkg_linux - - function_7-amplify_e2e_tests_pkg_linux: + - hooks-amplify_e2e_tests_pkg_linux: context: *ref_11 post-steps: *ref_12 filters: *ref_13 requires: - auth_1-amplify_e2e_tests_pkg_linux - - api_4-amplify_e2e_tests_pkg_linux: + - auth_6-amplify_e2e_tests_pkg_linux: context: *ref_11 post-steps: *ref_12 filters: *ref_13 diff --git a/packages/amplify-cli-core/src/__tests__/hooks/hooksExecutor.test.ts b/packages/amplify-cli-core/src/__tests__/hooks/hooksExecutor.test.ts new file mode 100644 index 00000000000..90600dc4d0a --- /dev/null +++ b/packages/amplify-cli-core/src/__tests__/hooks/hooksExecutor.test.ts @@ -0,0 +1,168 @@ +import * as path from 'path'; +import { executeHooks, HooksMeta, skipHooksFilePath } from '../../hooks'; +import * as skipHooksModule from '../../hooks/skipHooks'; +import * as execa from 'execa'; +import { pathManager, stateManager } from '../../state-manager'; +import * as fs from 'fs-extra'; + +const pathToPython3Runtime = 'path/to/python3/runtime'; +const pathToPythonRuntime = 'path/to/python/runtime'; +const pathToNodeRuntime = 'path/to/node/runtime'; +const preStatusNodeFileName = 'pre-status.js'; +const preStatusPythonFileName = 'pre-status.py'; +const preAddFileName = 'pre-add.js'; +const preAddAuthFileName = 'pre-add-auth.js'; +const postAddFileName = 'post-add.js'; +const postAddAuthFileName = 'post-add-auth.js'; +const testProjectRootPath = 'testProjectRootPath'; +const testProjectHooksDirPath = 'testProjectHooksDirPath'; + +const testProjectHooksFiles = [ + preStatusNodeFileName, + preStatusPythonFileName, + preAddFileName, + preAddAuthFileName, + postAddFileName, + postAddAuthFileName, + 'pre-pull.py', + 'pre-push.py', +]; + +const stateManager_mock = stateManager as jest.Mocked; +const pathManager_mock = pathManager as jest.Mocked; + +pathManager_mock.findProjectRoot.mockReturnValue(testProjectRootPath); +pathManager_mock.getHooksDirPath.mockReturnValue(testProjectHooksDirPath); +stateManager_mock.getHooksConfigJson.mockReturnValueOnce({ extensions: { py: { runtime: 'python3' } } }); + +jest.mock('execa'); +jest.mock('process'); +jest.mock('../../state-manager'); +jest.mock('which', () => ({ + sync: jest.fn().mockImplementation(runtimeName => { + if (runtimeName == 'python3') return pathToPython3Runtime; + else if (runtimeName == 'python') return pathToPythonRuntime; + else if (runtimeName == 'node') return pathToNodeRuntime; + }), +})); +jest.mock('fs-extra', () => { + const actualFs = jest.requireActual('fs-extra'); + return { + ...Object.assign({}, actualFs), + readdirSync: jest.fn().mockImplementation((path, options) => { + if (path === testProjectHooksDirPath) { + return testProjectHooksFiles; + } + return actualFs.readdirSync(path, options); + }), + lstatSync: jest.fn().mockImplementation(pathStr => { + if (testProjectHooksFiles.includes(path.relative(testProjectHooksDirPath, pathStr))) + return { isFile: jest.fn().mockReturnValue(true) }; + return actualFs.lstatSync(pathStr); + }), + existsSync: jest.fn().mockImplementation(path => { + if (path === testProjectHooksDirPath) return true; + return actualFs.existsSync(path); + }), + }; +}); +jest.mock('../../hooks/hooksConstants', () => { + const orgConstants = jest.requireActual('../../hooks/hooksConstants'); + return { ...Object.assign({}, orgConstants), skipHooksFilePath: path.join(__dirname, '..', 'testFiles', 'skiphooktestfile') }; +}); + +let mockSkipHooks = jest.spyOn(skipHooksModule, 'skipHooks'); + +describe('hooksExecutioner tests', () => { + beforeEach(async () => { + HooksMeta.getInstance(); + mockSkipHooks.mockReturnValue(false); + jest.clearAllMocks(); + }); + afterEach(() => { + HooksMeta.releaseInstance(); + }); + + test('skip Hooks test', async () => { + mockSkipHooks.mockRestore(); + + const orgSkipHooksExist = fs.existsSync(skipHooksFilePath); + + fs.ensureFileSync(skipHooksFilePath); + // skip hooks file exists so no execa calls should be made + await executeHooks(HooksMeta.getInstance({ command: 'push', plugin: 'core' }, 'pre')); + expect(execa).toHaveBeenCalledTimes(0); + + fs.removeSync(skipHooksFilePath); + // skip hooks file does not exists so execa calls should be made + await executeHooks(HooksMeta.getInstance({ command: 'push', plugin: 'core' }, 'pre')); + expect(execa).not.toHaveBeenCalledTimes(0); + + // resoring the original state of skip hooks file + if (!orgSkipHooksExist) fs.removeSync(skipHooksFilePath); + else fs.ensureFileSync(skipHooksFilePath); + mockSkipHooks = jest.spyOn(skipHooksModule, 'skipHooks'); + }); + + test('executeHooks with no context', async () => { + await executeHooks(HooksMeta.getInstance()); + expect(execa).toHaveBeenCalledTimes(0); + const hooksMeta = HooksMeta.getInstance(); + hooksMeta.setEventCommand('add'); + await executeHooks(HooksMeta.getInstance(undefined, 'pre')); + expect(execa).toHaveBeenCalledTimes(1); + }); + + test('should not call execa for unrecognised events', async () => { + await executeHooks(HooksMeta.getInstance({ command: 'init', plugin: 'core' }, 'pre')); + expect(execa).toHaveBeenCalledTimes(0); + }); + + test('should execute in specificity execution order', async () => { + await executeHooks(HooksMeta.getInstance({ command: 'add', plugin: 'auth' }, 'pre')); + expect(execa).toHaveBeenNthCalledWith(1, pathToNodeRuntime, [path.join(testProjectHooksDirPath, preAddFileName)], expect.anything()); + expect(execa).toHaveBeenNthCalledWith( + 2, + pathToNodeRuntime, + [path.join(testProjectHooksDirPath, preAddAuthFileName)], + expect.anything(), + ); + + await executeHooks(HooksMeta.getInstance({ command: 'add', plugin: 'auth' }, 'post')); + expect(execa).toHaveBeenNthCalledWith(3, pathToNodeRuntime, [path.join(testProjectHooksDirPath, postAddFileName)], expect.anything()); + expect(execa).toHaveBeenNthCalledWith( + 4, + pathToNodeRuntime, + [path.join(testProjectHooksDirPath, postAddAuthFileName)], + expect.anything(), + ); + }); + + test('should determine runtime from hooks-config', async () => { + stateManager_mock.getHooksConfigJson.mockReturnValueOnce({ extensions: { py: { runtime: 'python3' } } }); + await executeHooks(HooksMeta.getInstance({ command: 'pull', plugin: 'core' }, 'pre')); + expect(execa).toHaveBeenCalledWith(pathToPython3Runtime, expect.anything(), expect.anything()); + }); + + test('should determine windows runtime from hooks-config', async () => { + stateManager_mock.getHooksConfigJson.mockReturnValueOnce({ + extensions: { py: { runtime: 'python3', runtime_windows: 'python' } }, + }); + Object.defineProperty(process, 'platform', { value: 'win32' }); + await executeHooks(HooksMeta.getInstance({ command: 'pull', plugin: 'core' }, 'pre')); + expect(execa).toHaveBeenCalledWith(pathToPythonRuntime, expect.anything(), expect.anything()); + }); + + test('should not run the script for undefined extension/runtime', async () => { + await executeHooks(HooksMeta.getInstance({ command: 'pull', plugin: 'core' }, 'pre')); + expect(execa).toBeCalledTimes(0); + }); + + test('should throw error if duplicate hook scripts are present', async () => { + const duplicateErrorThrown = 'found duplicate hook scripts: ' + preStatusNodeFileName + ', ' + preStatusPythonFileName; + stateManager_mock.getHooksConfigJson.mockReturnValueOnce({ + extensions: { py: { runtime: 'python3' } }, + }); + await expect(executeHooks(HooksMeta.getInstance({ command: 'status', plugin: 'core' }, 'pre'))).rejects.toThrow(duplicateErrorThrown); + }); +}); diff --git a/packages/amplify-cli-core/src/__tests__/hooks/hooksMeta.test.ts b/packages/amplify-cli-core/src/__tests__/hooks/hooksMeta.test.ts new file mode 100644 index 00000000000..c64237deb92 --- /dev/null +++ b/packages/amplify-cli-core/src/__tests__/hooks/hooksMeta.test.ts @@ -0,0 +1,88 @@ +import { HooksMeta } from '../../hooks/hooksMeta'; + +describe('HooksMeta tests', () => { + beforeEach(async () => { + HooksMeta.getInstance(); + }); + afterEach(() => { + HooksMeta.releaseInstance(); + }); + + test('should identify env commands', () => { + const input = { command: 'env', plugin: 'core', subCommands: ['add'] }; + const hooksMeta = HooksMeta.getInstance(); + hooksMeta.setHookEventFromInput(input); + + expect(hooksMeta.getHookEvent()?.command).toEqual('add'); + expect(hooksMeta.getHookEvent()?.subCommand).toEqual('env'); + }); + + test('should identify configure as update for notification and hosting', () => { + let hooksMeta = HooksMeta.getInstance(); + + hooksMeta.setHookEventFromInput({ command: 'configure', plugin: 'notifications' }); + expect(hooksMeta.getHookEvent()?.command).toEqual('update'); + expect(hooksMeta.getHookEvent()?.subCommand).toEqual('notifications'); + + HooksMeta.releaseInstance(); + hooksMeta = HooksMeta.getInstance(); + + hooksMeta.setHookEventFromInput({ command: 'configure', plugin: 'hosting' }); + expect(hooksMeta.getHookEvent()?.command).toEqual('update'); + expect(hooksMeta.getHookEvent()?.subCommand).toEqual('hosting'); + }); + + test('should idenfity mock commands', () => { + const input = { command: 'api', plugin: 'mock' }; + const hooksMeta = HooksMeta.getInstance(); + hooksMeta.setHookEventFromInput(input); + + expect(hooksMeta.getHookEvent()?.command).toEqual('mock'); + expect(hooksMeta.getHookEvent()?.subCommand).toEqual('api'); + }); + + test('should not set the command and subcommand on unknown/unsupported events', () => { + const input = { command: 'init', plugin: 'core' }; + const hooksMeta = HooksMeta.getInstance(); + hooksMeta.setHookEventFromInput(input); + + expect(hooksMeta.getHookEvent()?.command).toEqual(undefined); + expect(hooksMeta.getHookEvent()?.subCommand).toEqual(undefined); + }); + + test('should return correct HooksMeta object - getInstance', () => { + let hooksMeta = HooksMeta.getInstance(); + + expect(hooksMeta).toBeDefined(); + expect(hooksMeta.getHookEvent()).toBeDefined(); + expect(hooksMeta.getDataParameter()).toBeDefined(); + expect(hooksMeta.getErrorParameter()).not.toBeDefined(); + + HooksMeta.releaseInstance(); + + hooksMeta = HooksMeta.getInstance( + { + command: 'pull', + plugin: 'core', + subCommands: undefined, + options: { + forcePush: true, + }, + }, + 'pre', + ); + expect(hooksMeta).toBeDefined(); + expect(hooksMeta.getHookEvent().command).toEqual('pull'); + expect(hooksMeta.getHookEvent().eventPrefix).toEqual('pre'); + expect(hooksMeta.getHookEvent().forcePush).toEqual(true); + expect(hooksMeta.getErrorParameter()).not.toBeDefined(); + + // if the event was defined and Amplify emits an error, getInstance should attatch the error parameter to the already defined event + hooksMeta = HooksMeta.getInstance(undefined, 'post', { message: 'test_message', stack: 'test_stack' }); + expect(hooksMeta).toBeDefined(); + expect(hooksMeta.getHookEvent().command).toEqual('pull'); + expect(hooksMeta.getHookEvent().eventPrefix).toEqual('post'); + expect(hooksMeta.getHookEvent().forcePush).toEqual(true); + expect(hooksMeta.getErrorParameter()).toEqual({ message: 'test_message', stack: 'test_stack' }); + }); +}); diff --git a/packages/amplify-cli-core/src/hooks/hooksConstants.ts b/packages/amplify-cli-core/src/hooks/hooksConstants.ts new file mode 100644 index 00000000000..1f75c69f964 --- /dev/null +++ b/packages/amplify-cli-core/src/hooks/hooksConstants.ts @@ -0,0 +1,45 @@ +import { join } from 'path'; +import { homedir } from 'os'; +import { HookExtensions, HooksNoun, HooksVerb } from './hooksTypes'; + +export const hookFileSeperator = '-'; + +export const suppportedEvents: Record = { + add: [ + 'notifications', + 'analytics', + 'api', + 'auth', + 'function', + 'hosting', + 'interactions', + 'predictions', + 'storage', + 'xr', + 'codegen', + 'env', + ], + update: ['notifications', 'analytics', 'api', 'auth', 'function', 'hosting', 'interactions', 'predictions', 'storage', 'xr', 'env'], + remove: ['notifications', 'analytics', 'api', 'auth', 'function', 'hosting', 'interactions', 'predictions', 'storage', 'xr', 'env'], + push: ['analytics', 'api', 'auth', 'function', 'hosting', 'interactions', 'storage', 'xr'], + pull: ['env'], + publish: [], + delete: [], + checkout: ['env'], + list: ['env'], + get: ['env'], + mock: ['api', 'storage', 'function'], + build: ['function'], + status: ['notifications'], + import: ['auth', 'storage', 'env'], + gqlcompile: ['api'], + addgraphqldatasource: ['api'], + statements: ['codegen'], + types: ['codegen'], +}; + +export const supportedEnvEvents: HooksVerb[] = ['add', 'update', 'remove', 'pull', 'checkout', 'list', 'get', 'import']; + +export const defaultSupportedExt: HookExtensions = { js: { runtime: 'node' }, sh: { runtime: 'bash' } }; + +export const skipHooksFilePath: string = '/opt/amazon'; diff --git a/packages/amplify-cli-core/src/hooks/hooksExecutor.ts b/packages/amplify-cli-core/src/hooks/hooksExecutor.ts new file mode 100644 index 00000000000..a9080a9604b --- /dev/null +++ b/packages/amplify-cli-core/src/hooks/hooksExecutor.ts @@ -0,0 +1,187 @@ +import { pathManager, stateManager } from '../state-manager'; +import { HooksConfig, HookExtensions, HookFileMeta, HookEvent, DataParameter, ErrorParameter } from './hooksTypes'; +import { defaultSupportedExt, hookFileSeperator } from './hooksConstants'; +import { skipHooks } from './skipHooks'; +import * as which from 'which'; +import * as fs from 'fs-extra'; +import * as path from 'path'; +import execa from 'execa'; +import { HooksMeta } from './hooksMeta'; +import _ from 'lodash'; +import { getLogger } from '../logger/index'; +import { EOL } from 'os'; +import { printer } from 'amplify-prompts'; +const logger = getLogger('amplify-cli-core', 'hooks/hooksExecutioner.ts'); + +export const executeHooks = async (hooksMeta: HooksMeta): Promise => { + if (skipHooks()) { + return; + } + + const projectPath = pathManager.findProjectRoot() ?? process.cwd(); + const hooksDirPath = pathManager.getHooksDirPath(projectPath); + if (!fs.existsSync(hooksDirPath)) { + return; + } + + const hooksConfig: HooksConfig = stateManager.getHooksConfigJson(projectPath) ?? {}; + + const { commandHookFileMeta, subCommandHookFileMeta } = getHookFileMetas(hooksDirPath, hooksMeta.getHookEvent(), hooksConfig); + + const executionQueue = [commandHookFileMeta, subCommandHookFileMeta]; + + if (hooksMeta.getHookEvent().forcePush) { + // we want to run push related hoooks when forcePush flag is enabled + hooksMeta.setEventCommand('push'); + hooksMeta.setEventSubCommand(undefined); + const { commandHookFileMeta } = getHookFileMetas(hooksDirPath, hooksMeta.getHookEvent(), hooksConfig); + executionQueue.push(commandHookFileMeta); + } + + for (const execFileMeta of executionQueue) { + if (!execFileMeta) { + continue; + } + const runtime = getRuntime(execFileMeta, hooksConfig); + if (!runtime) { + continue; + } + await execHelper(runtime, execFileMeta, hooksMeta.getDataParameter(), hooksMeta.getErrorParameter()); + } +}; + +const execHelper = async ( + runtime: string, + execFileMeta: HookFileMeta, + dataParameter: DataParameter, + errorParameter?: ErrorParameter, +): Promise => { + if (!execFileMeta?.filePath) { + return; + } + + const projectRoot = pathManager.findProjectRoot() ?? process.cwd(); + if (!projectRoot) { + return; + } + + printer.blankLine(); + printer.info(`----- 🪝 ${execFileMeta.baseName} execution start -----`); + + try { + logger.info(`hooks file: ${execFileMeta.fileName} execution started`); + const childProcess = execa(runtime, [execFileMeta.filePath], { + cwd: projectRoot, + env: { PATH: process.env.PATH }, + input: JSON.stringify({ + data: dataParameter, + error: errorParameter, + }), + stripFinalNewline: false, + }); + childProcess?.stdout?.pipe(process.stdout); + const childProcessResult = await childProcess; + if (!childProcessResult?.stdout?.endsWith(EOL)) { + printer.blankLine(); + } + logger.info(`hooks file: ${execFileMeta.fileName} execution ended`); + } catch (err) { + logger.info(`hooks file: ${execFileMeta.fileName} execution error - ${JSON.stringify(err)}`); + if (err?.stderr?.length > 0) { + printer.error(err.stderr); + } + if (err?.exitCode) { + printer.blankLine(); + printer.error(`${execFileMeta.baseName} hook script exited with exit code ${err.exitCode}`); + } + printer.blankLine(); + printer.error('exiting Amplify process...'); + printer.blankLine(); + logger.error('hook script exited with error', err); + // exit code is 76 indicating Amplify exited because user hook script exited with a non zero status + process.exit(76); + } + printer.info(`----- 🪝 ${execFileMeta.baseName} execution end -----`); + printer.blankLine(); +}; + +const getHookFileMetas = ( + hooksDirPath: string, + HookEvent: HookEvent, + hooksConfig: HooksConfig, +): { commandHookFileMeta?: HookFileMeta; subCommandHookFileMeta?: HookFileMeta } => { + if (!HookEvent.command) { + return {}; + } + const extensionsSupported = getSupportedExtensions(hooksConfig); + + const allFiles = fs + .readdirSync(hooksDirPath) + .filter(relFilePath => fs.lstatSync(path.join(hooksDirPath, relFilePath)).isFile()) + .map(relFilePath => splitFileName(relFilePath)) + .filter(fileMeta => fileMeta.extension && extensionsSupported.hasOwnProperty(fileMeta.extension)) + .map(fileMeta => ({ ...fileMeta, filePath: path.join(hooksDirPath, String(fileMeta.fileName)) })); + + const commandType = HookEvent.eventPrefix ? [HookEvent.eventPrefix, HookEvent.command].join(hookFileSeperator) : HookEvent.command; + const commandHooksFiles = allFiles.filter(fileMeta => fileMeta.baseName === commandType); + const commandHookFileMeta = throwOnDuplicateHooksFiles(commandHooksFiles); + + let subCommandHooksFiles; + let subCommandHookFileMeta: HookFileMeta | undefined; + if (HookEvent.subCommand) { + const subCommandType = HookEvent.eventPrefix + ? [HookEvent.eventPrefix, HookEvent.command, HookEvent.subCommand].join(hookFileSeperator) + : [HookEvent.command, HookEvent.subCommand].join(hookFileSeperator); + + subCommandHooksFiles = allFiles.filter(fileMeta => fileMeta.baseName === subCommandType); + subCommandHookFileMeta = throwOnDuplicateHooksFiles(subCommandHooksFiles); + } + return { commandHookFileMeta, subCommandHookFileMeta }; +}; + +const throwOnDuplicateHooksFiles = (files: HookFileMeta[]): HookFileMeta | undefined => { + if (files.length > 1) { + throw new Error(`found duplicate hook scripts: ${files.map(file => file.fileName).join(', ')}`); + } else if (files.length === 1) { + return files[0]; + } +}; + +const splitFileName = (filename: string): HookFileMeta => { + const lastDotIndex = filename.lastIndexOf('.'); + const fileMeta: HookFileMeta = { fileName: filename, baseName: filename }; + if (lastDotIndex !== -1) { + fileMeta.baseName = filename.substring(0, lastDotIndex); + fileMeta.extension = filename.substring(lastDotIndex + 1); + } + return fileMeta; +}; + +const getRuntime = (fileMeta: HookFileMeta, hooksConfig: HooksConfig): string | undefined => { + const { extension } = fileMeta; + if (!extension) { + return; + } + const isWin = process.platform === 'win32' || process.env.OSTYPE === 'cygwin' || process.env.OSTYPE === 'msys'; + const extensionObj = getSupportedExtensions(hooksConfig); + + let runtime: string | undefined; + if (isWin) runtime = extensionObj?.[extension]?.runtime_windows; + runtime = runtime ?? extensionObj?.[extension]?.runtime; + if (!runtime) { + return; + } + + const executablePath = which.sync(runtime, { + nothrow: true, + }); + if (!executablePath) { + throw new Error(String('hooks runtime not found: ' + runtime)); + } + + return executablePath; +}; + +const getSupportedExtensions = (hooksConfig: HooksConfig): HookExtensions => { + return { ...defaultSupportedExt, ...hooksConfig?.extensions }; +}; diff --git a/packages/amplify-cli-core/src/hooks/hooksMeta.ts b/packages/amplify-cli-core/src/hooks/hooksMeta.ts new file mode 100644 index 00000000000..fda32c65ee9 --- /dev/null +++ b/packages/amplify-cli-core/src/hooks/hooksMeta.ts @@ -0,0 +1,150 @@ +import { HookEvent, DataParameter, EventPrefix, HooksVerb, HooksNoun, ErrorParameter } from './hooksTypes'; +import { suppportedEvents, supportedEnvEvents } from './hooksConstants'; +import { stateManager } from '../state-manager'; +import _ from 'lodash'; + +export class HooksMeta { + private static instance?: HooksMeta; + private hookEvent: Partial; + private dataParameter: DataParameter; + private errorParameter?: ErrorParameter; + + public static getInstance = ( + input?: { + command?: string; + plugin?: string; + subCommands?: string[]; + options?: { forcePush?: boolean }; + argv?: string[]; + }, + eventPrefix?: EventPrefix, + errorParameter?: ErrorParameter, + ): HooksMeta => { + if (!HooksMeta.instance) { + HooksMeta.instance = new HooksMeta(); + } + if (input) { + HooksMeta.instance.setHookEventFromInput(input); + } + HooksMeta.instance.setEventPrefix(eventPrefix); + if (stateManager.localEnvInfoExists()) { + HooksMeta.instance.setEnvironmentName(stateManager.getLocalEnvInfo()); + } + HooksMeta.instance.mergeDataParameter({ + amplify: { + command: HooksMeta.instance.getHookEvent().command, + subCommand: HooksMeta.instance.getHookEvent().subCommand, + argv: HooksMeta.instance.getHookEvent().argv, + }, + }); + HooksMeta.instance.setErrorParameter(errorParameter); + + return HooksMeta.instance; + }; + + private constructor() { + this.hookEvent = {}; + this.dataParameter = { amplify: {} }; + } + + public getDataParameter(): DataParameter { + return this.dataParameter; + } + + public getErrorParameter(): ErrorParameter | undefined { + return this.errorParameter; + } + + public getHookEvent(): HookEvent { + return this.hookEvent as HookEvent; + } + + public setEnvironmentName(envName?: string): void { + this.dataParameter.amplify.environment = envName; + } + + public setAmplifyVersion(amplifyVersion: string): void { + this.dataParameter.amplify.version = amplifyVersion; + } + + public setErrorParameter(errorParameter?: ErrorParameter): void { + this.errorParameter = errorParameter; + } + + public setEventCommand(command: string): void { + this.hookEvent.command = command; + } + public setEventSubCommand(subCommand?: string): void { + this.hookEvent.subCommand = subCommand; + } + + public setEventPrefix(prefix?: EventPrefix): void { + this.hookEvent.eventPrefix = prefix; + } + + public mergeDataParameter(newDataParameter: DataParameter): void { + this.dataParameter = _.merge(this.dataParameter, newDataParameter); + } + + public setHookEventFromInput(input?: { + command?: string; + plugin?: string; + subCommands?: string[]; + argv?: string[]; + options?: { forcePush?: boolean }; + }): void { + if (!input) { + return; + } + if (this.hookEvent.command) { + return; + } + + let command: string = input.command ?? ''; + let subCommand: string = input.plugin ?? ''; + + switch (command) { + case 'env': + subCommand = 'env'; + if (!input.subCommands || input.subCommands.length < 0 || !supportedEnvEvents.includes(input.subCommands[0] as HooksVerb)) { + return; + } + command = input.subCommands[0]; + break; + case 'configure': + if (input.plugin === 'notifications' || input.plugin === 'hosting') { + command = 'update'; + } + break; + case 'gql-compile': + command = 'gqlcompile'; + break; + case 'add-graphql-datasource': + command = 'addgraphqldatasource'; + break; + } + + if (subCommand === 'mock') { + subCommand = command; + command = 'mock'; + } + + if (suppportedEvents.hasOwnProperty(command)) { + this.hookEvent.command = command; + if (suppportedEvents?.[command as HooksVerb]?.includes(subCommand as HooksNoun)) { + this.hookEvent.subCommand = subCommand; + } + } + this.hookEvent.forcePush = (input?.options?.forcePush && this.hookEvent.command !== 'push') ?? false; + this.hookEvent.argv = input.argv; + } + + /** + * @internal + * private method used in unit tests to release the instance + * TODO: remove this to use jest.resetModules or jest.isolateModules + */ + public static releaseInstance = (): void => { + HooksMeta.instance = undefined; + }; +} diff --git a/packages/amplify-cli-core/src/hooks/hooksTypes.ts b/packages/amplify-cli-core/src/hooks/hooksTypes.ts new file mode 100644 index 00000000000..b0021636ef2 --- /dev/null +++ b/packages/amplify-cli-core/src/hooks/hooksTypes.ts @@ -0,0 +1,69 @@ +export type HookExtensions = Record; + +export type HooksConfig = { + extensions?: HookExtensions; + ignore?: string[]; +}; + +export type HookFileMeta = { + baseName: string; + extension?: string; + filePath?: string; + fileName: string; +}; + +export type EventPrefix = 'pre' | 'post'; + +export type HookEvent = { + command: string; + subCommand?: string; + argv: string[]; + eventPrefix?: EventPrefix; + forcePush: boolean; +}; + +export type DataParameter = { + amplify: { + version?: string; + environment?: string; + command?: string; + subCommand?: string; + argv?: string[]; + }; +}; + +export type ErrorParameter = { message: string; stack: string }; + +export type HooksVerb = + | 'add' + | 'update' + | 'remove' + | 'push' + | 'pull' + | 'publish' + | 'delete' + | 'checkout' + | 'list' + | 'get' + | 'mock' + | 'build' + | 'status' + | 'import' + | 'gqlcompile' + | 'addgraphqldatasource' + | 'statements' + | 'types'; + +export type HooksNoun = + | 'notifications' + | 'analytics' + | 'api' + | 'auth' + | 'function' + | 'hosting' + | 'interactions' + | 'predictions' + | 'storage' + | 'xr' + | 'codegen' + | 'env'; diff --git a/packages/amplify-cli-core/src/hooks/index.ts b/packages/amplify-cli-core/src/hooks/index.ts new file mode 100644 index 00000000000..4157287c98a --- /dev/null +++ b/packages/amplify-cli-core/src/hooks/index.ts @@ -0,0 +1,5 @@ +export * from './skipHooks'; +export * from './hooksTypes'; +export * from './hooksConstants'; +export * from './hooksMeta'; +export * from './hooksExecutor'; diff --git a/packages/amplify-cli-core/src/hooks/skipHooks.ts b/packages/amplify-cli-core/src/hooks/skipHooks.ts new file mode 100644 index 00000000000..10ae6e74bb6 --- /dev/null +++ b/packages/amplify-cli-core/src/hooks/skipHooks.ts @@ -0,0 +1,11 @@ +import * as fs from 'fs-extra'; +import { skipHooksFilePath } from './hooksConstants'; + +export function skipHooks(): boolean { + // DO NOT CHANGE: used to skip hooks on Admin UI + try { + return fs.existsSync(skipHooksFilePath); + } catch (err) { + return false; + } +} diff --git a/packages/amplify-cli-core/src/index.ts b/packages/amplify-cli-core/src/index.ts index 776a1679ac0..98bef730dc8 100644 --- a/packages/amplify-cli-core/src/index.ts +++ b/packages/amplify-cli-core/src/index.ts @@ -23,6 +23,7 @@ export * from './banner-message'; export * from './cliGetCategories'; export * from './cliRemoveResourcePrompt'; export * from './cliViewAPI'; +export * from './hooks'; // Temporary types until we can finish full type definition across the whole CLI diff --git a/packages/amplify-cli-core/src/state-manager/pathManager.ts b/packages/amplify-cli-core/src/state-manager/pathManager.ts index d7c0425fd38..7efee8c5dc2 100644 --- a/packages/amplify-cli-core/src/state-manager/pathManager.ts +++ b/packages/amplify-cli-core/src/state-manager/pathManager.ts @@ -19,6 +19,7 @@ export const PathConstants = { DotConfigDirName: '.config', BackendDirName: 'backend', CurrentCloudBackendDirName: '#current-cloud-backend', + HooksDirName: 'hooks', // FileNames AmplifyAdminConfigFileName: 'config.json', @@ -31,6 +32,8 @@ export const PathConstants = { ParametersJsonFileName: 'parameters.json', ReadMeFileName: 'README.md', + HooksConfigFileName: 'hooks-config.json', + LocalEnvFileName: 'local-env-info.json', LocalAWSInfoFileName: 'local-aws-info.json', TeamProviderInfoFileName: 'team-provider-info.json', @@ -156,6 +159,12 @@ export class PathManager { getDeploymentSecrets = (): string => path.normalize(path.join(this.getDotAWSAmplifyDirPath(), PathConstants.DeploymentSecretsFileName)); + getHooksDirPath = (projectPath?: string): string => + this.constructPath(projectPath, [PathConstants.AmplifyDirName, PathConstants.HooksDirName]); + + getHooksConfigFilePath = (projectPath?: string): string => + path.join(this.getHooksDirPath(projectPath), PathConstants.HooksConfigFileName); + private constructPath = (projectPath?: string, segments: string[] = []): string => { if (!projectPath) { projectPath = this.findProjectRoot(); diff --git a/packages/amplify-cli-core/src/state-manager/stateManager.ts b/packages/amplify-cli-core/src/state-manager/stateManager.ts index 227a1d7b5f9..a0489368cbf 100644 --- a/packages/amplify-cli-core/src/state-manager/stateManager.ts +++ b/packages/amplify-cli-core/src/state-manager/stateManager.ts @@ -1,6 +1,7 @@ import * as fs from 'fs-extra'; +import * as path from 'path'; import _ from 'lodash'; -import { $TSAny, $TSMeta, $TSTeamProviderInfo, DeploymentSecrets } from '..'; +import { $TSAny, $TSMeta, $TSTeamProviderInfo, DeploymentSecrets, HooksConfig } from '..'; import { SecretFileMode } from '../cliConstants'; import { JSONUtilities } from '../jsonUtilities'; import { HydrateTags, ReadTags, Tag } from '../tags'; @@ -236,6 +237,14 @@ export class StateManager { JSONUtilities.writeJson(filePath, meta); }; + getHooksConfigJson = (projectPath?: string): HooksConfig => this.getData(pathManager.getHooksConfigFilePath(projectPath), {throwIfNotExist: false}) ?? {}; + + setSampleHooksDir = (projectPath: string | undefined, sourceDirPath: string): void => { + const targetDirPath = pathManager.getHooksDirPath(projectPath); + // only create the hooks directory with sample hooks if the directory doesnt already exists + if (!fs.existsSync(targetDirPath)) fs.copySync(sourceDirPath, targetDirPath); + }; + setResourceParametersJson = (projectPath: string | undefined, category: string, resourceName: string, parameters: $TSAny): void => { const filePath = pathManager.getResourceParametersFilePath(projectPath, category, resourceName); diff --git a/packages/amplify-cli/resources/sample-hooks/Readme.md b/packages/amplify-cli/resources/sample-hooks/Readme.md new file mode 100644 index 00000000000..8fb601eaebe --- /dev/null +++ b/packages/amplify-cli/resources/sample-hooks/Readme.md @@ -0,0 +1,7 @@ +# Command Hooks + +Command hooks can be used to run custom scripts upon Amplify CLI lifecycle events like pre-push, post-add-function, etc. + +To get started, add your script files based on the expected naming convention in this directory. + +Learn more about the script file naming convention, hook parameters, third party dependencies, and advanced configurations at https://docs.amplify.aws/cli/usage/command-hooks diff --git a/packages/amplify-cli/resources/sample-hooks/post-push.sh.sample b/packages/amplify-cli/resources/sample-hooks/post-push.sh.sample new file mode 100644 index 00000000000..20df3f3ca6a --- /dev/null +++ b/packages/amplify-cli/resources/sample-hooks/post-push.sh.sample @@ -0,0 +1,24 @@ +# This is a sample hook script created by Amplify CLI. +# To start using this post-push hook please change the filename: +# post-push.sh.sample -> post-push.sh +# +# learn more: https://docs.amplify.aws/cli/usage/command-hooks + +if [ -z "$(which jq)" ]; then + echo "Please install jq to run the sample script." + exit 0 +fi + +parameters=`cat` +error=$(jq -r '.error // empty' <<< "$parameters") +data=$(jq -r '.data' <<< "$parameters") + +# +# Write code here: +# +if [ ! -z "$error" ]; then + echo "Amplify CLI emitted an error:" $(jq -r '.message' <<< "$error") + exit 0 +fi +echo "project root path:" $(pwd); +echo "Amplify CLI command:" $(jq -r '.amplify | .command' <<< "$data") \ No newline at end of file diff --git a/packages/amplify-cli/resources/sample-hooks/pre-push.js.sample b/packages/amplify-cli/resources/sample-hooks/pre-push.js.sample new file mode 100644 index 00000000000..402e8f9ce36 --- /dev/null +++ b/packages/amplify-cli/resources/sample-hooks/pre-push.js.sample @@ -0,0 +1,27 @@ +/** + * This is a sample hook script created by Amplify CLI. + * To start using this pre-push hook please change the filename: + * pre-push.js.sample -> pre-push.js + * + * learn more: https://docs.amplify.aws/cli/usage/command-hooks + */ + +/** + * @param data { { amplify: { environment: string, command: string, subCommand: string, argv: string[] } } } + * @param error { { message: string, stack: string } } + */ +const hookHandler = async (data, error) => { + // TODO write your hook handler here +}; + +const getParameters = async () => { + const fs = require("fs"); + return JSON.parse(fs.readFileSync(0, { encoding: "utf8" })); +}; + +getParameters() + .then((event) => hookHandler(event.data, event.error)) + .catch((err) => { + console.error(err); + process.exitCode = 1; + }); diff --git a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/git-manager.test.ts b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/git-manager.test.ts index ca489e83dbd..ff4586a7cd8 100644 --- a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/git-manager.test.ts +++ b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/git-manager.test.ts @@ -32,6 +32,7 @@ const ignoreList = [ 'amplify-gradle-config.json', 'amplifytools.xcconfig', '.secret-*', + '**.sample', ]; const toAppend = `${os.EOL + os.EOL + amplifyMark + os.EOL}${ignoreList.join(os.EOL)}${os.EOL + amplifyEndMark + os.EOL}`; diff --git a/packages/amplify-cli/src/__tests__/test-aborting.test.ts b/packages/amplify-cli/src/__tests__/test-aborting.test.ts index 6fac43283a1..75587235c0e 100644 --- a/packages/amplify-cli/src/__tests__/test-aborting.test.ts +++ b/packages/amplify-cli/src/__tests__/test-aborting.test.ts @@ -41,6 +41,14 @@ describe('test SIGINT with execute', () => { DeploymentSecretsFileName: 'deployment-secrets.json', }, CLIContextEnvironmentProvider: jest.fn(), + executeHooks: jest.fn(), + HooksMeta: { + getInstance: jest.fn().mockReturnValue({ + setAmplifyVersion: jest.fn(), + setHookEventFromInput: jest.fn(), + }), + }, + skipHooks: jest.fn(), }); jest.setMock('../plugin-manager', { getPluginPlatform: jest.fn(), @@ -72,6 +80,7 @@ describe('test SIGINT with execute', () => { }; mockContext.projectHasMobileHubResources = false; mockContext.amplify = jest.genMockFromModule('../domain/amplify-toolkit'); + Object.defineProperty(mockContext.amplify, 'getEnvInfo', { value: jest.fn() }); jest.setMock('../context-manager', { constructContext: jest.fn().mockReturnValue(mockContext), attachUsageData: jest.fn(), diff --git a/packages/amplify-cli/src/commands/push.ts b/packages/amplify-cli/src/commands/push.ts index 426839e4fce..e5893af3aea 100644 --- a/packages/amplify-cli/src/commands/push.ts +++ b/packages/amplify-cli/src/commands/push.ts @@ -38,6 +38,17 @@ async function syncCurrentCloudBackend(context: $TSContext) { } } +async function pushHooks(context: $TSContext) { + context.exeInfo.pushHooks = true; + const providerPlugins = getProviderPlugins(context); + const pushHooksTasks: (() => Promise<$TSAny>)[] = []; + context.exeInfo.projectConfig.providers.forEach(provider => { + const providerModule = require(providerPlugins[provider]); + pushHooksTasks.push(() => providerModule.uploadHooksDirectory(context)); + }); + await sequential(pushHooksTasks); +} + export const run = async (context: $TSContext) => { try { context.amplify.constructExeInfo(context); @@ -47,6 +58,7 @@ export const run = async (context: $TSContext) => { if (context.parameters.options.force) { context.exeInfo.forcePush = true; } + await pushHooks(context); await syncCurrentCloudBackend(context); return await context.amplify.pushResources(context); } catch (e) { diff --git a/packages/amplify-cli/src/commands/version.ts b/packages/amplify-cli/src/commands/version.ts index 3f2284ecb9f..3611d9ea176 100644 --- a/packages/amplify-cli/src/commands/version.ts +++ b/packages/amplify-cli/src/commands/version.ts @@ -1,9 +1,7 @@ -import * as path from 'path'; import { Context } from '../domain/context'; -import { JSONUtilities, $TSAny } from 'amplify-cli-core'; +import { printer } from 'amplify-prompts'; +import { getAmplifyVersion } from '../extensions/amplify-helpers/get-amplify-version'; export const run = (context: Context) => { - const pkg = JSONUtilities.readJson<$TSAny>(path.join(__dirname, '..', '..', 'package.json')); - - context.print.info(pkg.version); + printer.info(getAmplifyVersion()); }; diff --git a/packages/amplify-cli/src/display-banner-messages.ts b/packages/amplify-cli/src/display-banner-messages.ts index 5444578ec06..38430214575 100644 --- a/packages/amplify-cli/src/display-banner-messages.ts +++ b/packages/amplify-cli/src/display-banner-messages.ts @@ -1,6 +1,6 @@ -import { $TSAny, BannerMessage, pathManager, stateManager } from 'amplify-cli-core'; +import { $TSAny, BannerMessage, pathManager, stateManager, skipHooks } from 'amplify-cli-core'; import { isCI } from 'ci-info'; -import { print } from './context-extensions'; +import { printer } from 'amplify-prompts'; import { Input } from './domain/input'; export async function displayBannerMessages(input: Input) { @@ -9,6 +9,10 @@ export async function displayBannerMessages(input: Input) { return; } await displayLayerMigrationMessage(); + if (skipHooks()) { + printer.warn('Amplify command hooks are disabled in the current execution environment.'); + printer.warn('See https://docs.amplify.aws/cli/usage/runtime-hooks for more information.'); + } } async function displayLayerMigrationMessage() { @@ -28,8 +32,8 @@ async function displayLayerMigrationMessage() { ).length > 0; if (hasDeprecatedLayerResources && layerMigrationBannerMessage) { - print.info(''); - print.warning(layerMigrationBannerMessage); - print.info(''); + printer.blankLine(); + printer.warn(layerMigrationBannerMessage); + printer.blankLine(); } } diff --git a/packages/amplify-cli/src/execution-manager.ts b/packages/amplify-cli/src/execution-manager.ts index be63043b01b..f0a2e08d78c 100644 --- a/packages/amplify-cli/src/execution-manager.ts +++ b/packages/amplify-cli/src/execution-manager.ts @@ -1,7 +1,7 @@ import * as fs from 'fs-extra'; import * as path from 'path'; import * as inquirer from 'inquirer'; -import { $TSAny, stateManager } from 'amplify-cli-core'; +import { $TSAny, stateManager, executeHooks, HooksMeta } from 'amplify-cli-core'; import { twoStringSetsAreEqual, twoStringSetsAreDisjoint } from './utils/set-ops'; import { Context } from './domain/context'; import { constants } from './domain/constants'; @@ -236,6 +236,7 @@ const legacyCommandExecutor = async (context: Context, plugin: PluginInfo) => { const EVENT_EMITTING_PLUGINS = new Set([constants.CORE, constants.CODEGEN]); async function raisePreEvent(context: Context) { + await executeHooks(HooksMeta.getInstance(context.input, 'pre')); const { command, plugin } = context.input; if (!plugin || !EVENT_EMITTING_PLUGINS.has(plugin)) { return; @@ -275,6 +276,7 @@ async function raisePreCodegenModelsEvent(context: Context) { async function raisePostEvent(context: Context) { const { command, plugin } = context.input; if (!plugin || !EVENT_EMITTING_PLUGINS.has(plugin)) { + await executeHooks(HooksMeta.getInstance(context.input, 'post')); return; } switch (command) { @@ -291,6 +293,7 @@ async function raisePostEvent(context: Context) { await raisePostCodegenModelsEvent(context); break; } + await executeHooks(HooksMeta.getInstance(context.input, 'post')); } async function raisePostInitEvent(context: Context) { diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/get-amplify-version.ts b/packages/amplify-cli/src/extensions/amplify-helpers/get-amplify-version.ts new file mode 100644 index 00000000000..241eb7f2e1f --- /dev/null +++ b/packages/amplify-cli/src/extensions/amplify-helpers/get-amplify-version.ts @@ -0,0 +1,7 @@ +import * as path from 'path'; +import { JSONUtilities, $TSAny } from 'amplify-cli-core'; + +export const getAmplifyVersion = (): string => { + const pkg = JSONUtilities.readJson<$TSAny>(path.join(__dirname, '..', '..', '..', 'package.json')); + return pkg.version; +}; diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/git-manager.ts b/packages/amplify-cli/src/extensions/amplify-helpers/git-manager.ts index b868a37cc52..a01a87e7115 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/git-manager.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/git-manager.ts @@ -66,6 +66,7 @@ function getGitIgnoreAppendString() { 'amplify-gradle-config.json', 'amplifytools.xcconfig', '.secret-*', + '**.sample', ]; const toAppend = `${os.EOL + os.EOL + amplifyMark + os.EOL}${ignoreList.join(os.EOL)}${os.EOL + amplifyEndMark + os.EOL}`; diff --git a/packages/amplify-cli/src/index.ts b/packages/amplify-cli/src/index.ts index 3c38012a6e7..e7ac6a0cdd2 100644 --- a/packages/amplify-cli/src/index.ts +++ b/packages/amplify-cli/src/index.ts @@ -10,6 +10,8 @@ import { pathManager, stateManager, TeamProviderInfoMigrateError, + executeHooks, + HooksMeta, } from 'amplify-cli-core'; import { isCI } from 'ci-info'; import { EventEmitter } from 'events'; @@ -31,7 +33,7 @@ import { ensureMobileHubCommandCompatibility } from './utils/mobilehub-support'; import { migrateTeamProviderInfo } from './utils/team-provider-migrate'; import { deleteOldVersion } from './utils/win-utils'; import { notify } from './version-notifier'; - +import { getAmplifyVersion } from './extensions/amplify-helpers/get-amplify-version'; // Adjust defaultMaxListeners to make sure Inquirer will not fail under Windows because of the multiple subscriptions // https://github.com/SBoudrias/Inquirer.js/issues/887 @@ -65,40 +67,40 @@ process.on('unhandledRejection', function (error) { throw error; }); -function convertKeysToLowerCase(obj : object){ - let newObj = {} - for( let key of Object.keys(obj) ){ +function convertKeysToLowerCase(obj: object) { + let newObj = {}; + for (let key of Object.keys(obj)) { newObj[key.toLowerCase()] = obj[key]; } return newObj; } -function normalizeStatusCommandOptions( input : Input ){ - let options = (input.options)?input.options:{}; +function normalizeStatusCommandOptions(input: Input) { + let options = input.options ? input.options : {}; const allowedVerboseIndicators = [constants.VERBOSE, 'v']; //Normalize 'amplify status -v' to verbose, since -v is interpreted as 'version' - for( let verboseFlag of allowedVerboseIndicators ){ - if ( options.hasOwnProperty(verboseFlag) ){ - if ( typeof options[verboseFlag] === 'string' ){ + for (let verboseFlag of allowedVerboseIndicators) { + if (options.hasOwnProperty(verboseFlag)) { + if (typeof options[verboseFlag] === 'string') { const pluginName = (options[verboseFlag] as string).toLowerCase(); - options[pluginName] = true ; + options[pluginName] = true; } - delete options[verboseFlag] + delete options[verboseFlag]; options['verbose'] = true; } } //Merge plugins and subcommands as options (except help/verbose) - if ( input.plugin ){ - options[ input.plugin ] = true; - delete input.plugin + if (input.plugin) { + options[input.plugin] = true; + delete input.plugin; } - if ( input.subCommands ){ + if (input.subCommands) { const allowedSubCommands = [constants.HELP, constants.VERBOSE]; //list of subcommands supported in Status - let inputSubCommands:string[] = []; - input.subCommands.map( subCommand => { + let inputSubCommands: string[] = []; + input.subCommands.map(subCommand => { //plugins are inferred as subcommands when positionally supplied - if( !allowedSubCommands.includes(subCommand) ) { - options[ subCommand.toLowerCase() ] = true; + if (!allowedSubCommands.includes(subCommand)) { + options[subCommand.toLowerCase()] = true; } else { inputSubCommands.push(subCommand); } @@ -116,6 +118,7 @@ export async function run() { let pluginPlatform = await getPluginPlatform(); let input = getCommandLineInput(pluginPlatform); + // with non-help command supplied, give notification before execution if (input.command !== 'help') { // Checks for available update, defaults to a 1 day interval for notification @@ -123,8 +126,8 @@ export async function run() { } //Normalize status command options - if ( input.command == 'status'){ - input = normalizeStatusCommandOptions(input) + if (input.command == 'status') { + input = normalizeStatusCommandOptions(input); } // Initialize Banner messages. These messages are set on the server side @@ -156,6 +159,8 @@ export async function run() { rewireDeprecatedCommands(input); logInput(input); + const hooksMeta = HooksMeta.getInstance(input); + hooksMeta.setAmplifyVersion(getAmplifyVersion()); const context = constructContext(pluginPlatform, input); // Initialize feature flags @@ -268,6 +273,12 @@ export async function run() { print.info(error.stack); } } + await executeHooks( + HooksMeta.getInstance(undefined, 'post', { + message: error.message ?? 'undefined error in Amplify process', + stack: error.stack ?? 'undefined error stack', + }), + ); exitOnNextTick(1); } } diff --git a/packages/amplify-cli/src/init-steps/s9-onSuccess.ts b/packages/amplify-cli/src/init-steps/s9-onSuccess.ts index 46d2167402a..c874111ba71 100644 --- a/packages/amplify-cli/src/init-steps/s9-onSuccess.ts +++ b/packages/amplify-cli/src/init-steps/s9-onSuccess.ts @@ -1,4 +1,5 @@ import * as fs from 'fs-extra'; +import { join } from 'path'; import sequential from 'promise-sequential'; import { CLIContextEnvironmentProvider, FeatureFlags, pathManager, stateManager, $TSContext } from 'amplify-cli-core'; import { getFrontendPlugins } from '../extensions/amplify-helpers/get-frontend-plugins'; @@ -123,6 +124,7 @@ function generateNonRuntimeFiles(context: $TSContext) { generateTeamProviderInfoFile(context); generateGitIgnoreFile(context); generateReadMeFile(context); + generateHooksSampleDirectory(context); } function generateProjectConfigFile(context: $TSContext) { @@ -177,6 +179,13 @@ function generateReadMeFile(context: $TSContext) { writeReadMeFile(readMeFilePath); } +function generateHooksSampleDirectory(context: $TSContext) { + const { projectPath } = context.exeInfo.localEnvInfo; + const sampleHookScriptsDirPath = join(__dirname, '..', '..', 'resources', 'sample-hooks'); + + stateManager.setSampleHooksDir(projectPath, sampleHookScriptsDirPath); +} + function printWelcomeMessage(context: $TSContext) { context.print.info(''); context.print.success('Your project has been successfully initialized and connected to the cloud!'); diff --git a/packages/amplify-e2e-core/src/init/amplifyPush.ts b/packages/amplify-e2e-core/src/init/amplifyPush.ts index 2d9d2de3a68..6bf71b874fa 100644 --- a/packages/amplify-e2e-core/src/init/amplifyPush.ts +++ b/packages/amplify-e2e-core/src/init/amplifyPush.ts @@ -246,3 +246,11 @@ export function amplifyPushMissingFuncSecret(cwd: string, newSecretValue: string .run(err => (err ? reject(err) : resolve())); }); } + +export function amplifyPushWithNoChanges(cwd: string, testingWithLatestCodebase: boolean = false): Promise { + return new Promise((resolve, reject) => { + spawn(getCLIPath(testingWithLatestCodebase), ['push'], { cwd, stripColors: true, noOutputTimeout: pushTimeoutMS }) + .wait('No changes detected') + .run((err: Error) => err ? reject(err) : resolve()); + }); +} diff --git a/packages/amplify-e2e-core/src/utils/hooks.ts b/packages/amplify-e2e-core/src/utils/hooks.ts new file mode 100644 index 00000000000..d92b1dc6ef5 --- /dev/null +++ b/packages/amplify-e2e-core/src/utils/hooks.ts @@ -0,0 +1,5 @@ +import * as path from 'path'; + +export const getHooksDirPath = (projRoot: string): string => { + return path.join(projRoot, 'amplify', 'hooks'); +}; diff --git a/packages/amplify-e2e-core/src/utils/index.ts b/packages/amplify-e2e-core/src/utils/index.ts index 105ab107871..58c82065280 100644 --- a/packages/amplify-e2e-core/src/utils/index.ts +++ b/packages/amplify-e2e-core/src/utils/index.ts @@ -22,6 +22,7 @@ export * from './selectors'; export * from './sleep'; export * from './transformConfig'; export * from './admin-ui'; +export * from './hooks'; // run dotenv config to update env variable config(); diff --git a/packages/amplify-e2e-core/src/utils/sdk-calls.ts b/packages/amplify-e2e-core/src/utils/sdk-calls.ts index af87e4b4f95..e5e60113372 100644 --- a/packages/amplify-e2e-core/src/utils/sdk-calls.ts +++ b/packages/amplify-e2e-core/src/utils/sdk-calls.ts @@ -60,6 +60,17 @@ export const getBucketEncryption = async (bucket: string) => { } }; +export const getBucketKeys = async (params: S3.ListObjectsRequest) => { + const s3 = new S3(); + + try { + const result = await s3.listObjects(params).promise(); + return result.Contents.map(contentObj => contentObj.Key); + } catch (err) { + throw new Error(`Error fetching keys for bucket ${params.Bucket}. Underlying error was [${err.message}]`); + } +}; + export const deleteS3Bucket = async (bucket: string) => { const s3 = new S3(); let continuationToken: Required> = undefined; diff --git a/packages/amplify-e2e-tests/hooks/hooks-config.json b/packages/amplify-e2e-tests/hooks/hooks-config.json new file mode 100644 index 00000000000..79258d1af00 --- /dev/null +++ b/packages/amplify-e2e-tests/hooks/hooks-config.json @@ -0,0 +1,3 @@ +{ + "ignore": "ignoredFile" +} diff --git a/packages/amplify-e2e-tests/hooks/ignoredFile b/packages/amplify-e2e-tests/hooks/ignoredFile new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/amplify-e2e-tests/hooks/post-add-function.js b/packages/amplify-e2e-tests/hooks/post-add-function.js new file mode 100644 index 00000000000..2adee3e400a --- /dev/null +++ b/packages/amplify-e2e-tests/hooks/post-add-function.js @@ -0,0 +1,2 @@ +const fs = require('fs'); +fs.writeFileSync('ping', 'testFile'); diff --git a/packages/amplify-e2e-tests/hooks/post-add.js b/packages/amplify-e2e-tests/hooks/post-add.js new file mode 100644 index 00000000000..6e163dd4aea --- /dev/null +++ b/packages/amplify-e2e-tests/hooks/post-add.js @@ -0,0 +1,2 @@ +const fs = require('fs'); +fs.writeFileSync('pong', 'testFile'); diff --git a/packages/amplify-e2e-tests/src/__tests__/hooks.test.ts b/packages/amplify-e2e-tests/src/__tests__/hooks.test.ts new file mode 100644 index 00000000000..8a2803dc19d --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/hooks.test.ts @@ -0,0 +1,113 @@ +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { initJSProjectWithProfile, deleteProject, createNewProjectDir, deleteProjectDir } from 'amplify-e2e-core'; +import { getProjectMeta, getBucketKeys, getHooksDirPath, getAppId } from 'amplify-e2e-core'; +import { amplifyPull } from 'amplify-e2e-core'; +import { amplifyPushWithNoChanges } from 'amplify-e2e-core'; +import { addFunction, removeFunction } from 'amplify-e2e-core'; +import { addEnvironment, checkoutEnvironment } from '../environment/env'; + +const checkForFiles = (toCheckFiles: string[], inFiles: string[], prefix?: string): void => { + toCheckFiles.forEach(toCheckFile => { + expect(inFiles).toContain(prefix ? prefix.concat(toCheckFile) : toCheckFile); + }); +}; + +describe('runtime hooks', () => { + let projRoot: string; + beforeEach(async () => { + projRoot = await createNewProjectDir('hooks'); + }); + + afterEach(async () => { + await deleteProject(projRoot); + deleteProjectDir(projRoot); + }); + + it('test hooks manager and execution', async () => { + const initFiles = ['Readme.md', 'pre-push.js.sample', 'post-push.sh.sample']; + const defaultHookFiles = ['Readme.md', 'hooks-config.json', 'post-add-function.js', 'post-add.js']; + const ignoredFileName = 'ignoredFile'; + const newFileName = 'newFile'; + + // amplify init + await initJSProjectWithProfile(projRoot, { disableAmplifyAppCreation: false, envName: 'enva' }); + const hooksDirPath = getHooksDirPath(projRoot); + let meta = getProjectMeta(projRoot).providers.awscloudformation; + expect(meta.DeploymentBucketName).toBeDefined(); + expect(fs.existsSync(hooksDirPath)).toBe(true); + + // init should create hooks sample files and readme + let hooksFiles = fs.readdirSync(hooksDirPath); + checkForFiles(initFiles, hooksFiles); + + // adding hook scripts and removing sample scripts + fs.removeSync(path.join(hooksDirPath, 'pre-push.js.sample')); + fs.removeSync(path.join(hooksDirPath, 'post-push.sh.sample')); + fs.copySync(path.join(__dirname, '..', '..', 'hooks'), hooksDirPath); + + // add function to test if hooks are recognised and executed corrrectly + await addFunction(projRoot, { functionTemplate: 'Hello World', name: 'funcName' }, 'nodejs'); + expect(fs.existsSync(path.join(projRoot, 'ping'))).toBe(true); + expect(fs.existsSync(path.join(projRoot, 'pong'))).toBe(true); + await removeFunction(projRoot, 'funcName'); + + // amplify push to push the hooks + await amplifyPushWithNoChanges(projRoot); + + // check if hooks were uploaded correctly to S3 + let S3Keys = await getBucketKeys({ Bucket: meta.DeploymentBucketName, Prefix: 'hooks/' }); + checkForFiles(defaultHookFiles, S3Keys, 'hooks/'); + // check if the inored file in hooks-config.json is recognised and not uploaded + expect(S3Keys).not.toContain('hooks/' + ignoredFileName); + + // amplify pull should get all hook scripts in the S3 bucket + const appId = getAppId(projRoot); + expect(appId).toBeDefined(); + const projRoot2 = await createNewProjectDir('hooks'); + await amplifyPull(projRoot2, { appId, emptyDir: true, noUpdateBackend: true }); + const hooksDirPath2 = getHooksDirPath(projRoot2); + expect(fs.existsSync(hooksDirPath2)).toBe(true); + const hooksFiles2 = fs.readdirSync(hooksDirPath2); + checkForFiles(defaultHookFiles, hooksFiles2); + expect(hooksFiles2).not.toContain(ignoredFileName); + fs.removeSync(projRoot2); + + // amplify env add should copy all hooks to new env and upload to S3 + await addEnvironment(projRoot, { envName: 'envb' }); + meta = getProjectMeta(projRoot).providers.awscloudformation; + hooksFiles = fs.readdirSync(hooksDirPath); + checkForFiles(defaultHookFiles, hooksFiles); + expect(hooksFiles).toContain(ignoredFileName); + + // check S3 + fs.writeFileSync(path.join(hooksDirPath, newFileName), 'test file in envb'); + await amplifyPushWithNoChanges(projRoot); + + S3Keys = await getBucketKeys({ Bucket: meta.DeploymentBucketName, Prefix: 'hooks/' }); + checkForFiles(defaultHookFiles, S3Keys, 'hooks/'); + expect(S3Keys).toContain('hooks/' + newFileName); + expect(S3Keys).not.toContain('hooks/' + ignoredFileName); + + // checkout env should pull and replace hooks directory with hooks for the checked out env + await checkoutEnvironment(projRoot, { envName: 'enva' }); + hooksFiles = fs.readdirSync(hooksDirPath); + checkForFiles(defaultHookFiles, hooksFiles); + expect(hooksFiles).toContain(ignoredFileName); + expect(hooksFiles).not.toContain(newFileName); + }); + + it('test hook scripts with non zero exit code', async () => { + await initJSProjectWithProfile(projRoot, { envName: 'enva' }); + const hooksDirPath = getHooksDirPath(projRoot); + expect(fs.existsSync(hooksDirPath)).toBe(true); + fs.removeSync(path.join(hooksDirPath, 'pre-push.js.sample')); + fs.removeSync(path.join(hooksDirPath, 'post-push.sh.sample')); + fs.writeFileSync(path.join(hooksDirPath, 'pre-add.js'), 'process.exit(1);'); + + // amplify process should exit as the hook script exited with non zero exit code + await expect(addFunction(projRoot, { functionTemplate: 'Hello World' }, 'nodejs')).rejects.toThrow(); + // expect function to be not created + expect(fs.readdirSync(path.join(projRoot, 'amplify', 'backend'))).not.toContain('function'); + }); +}); diff --git a/packages/amplify-provider-awscloudformation/src/__tests__/utils/hooks-manager.test.ts b/packages/amplify-provider-awscloudformation/src/__tests__/utils/hooks-manager.test.ts new file mode 100644 index 00000000000..c0e47bcd19c --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/__tests__/utils/hooks-manager.test.ts @@ -0,0 +1,185 @@ +import * as path from 'path'; +import { uploadHooksDirectory, downloadHooks, pullHooks, S3_HOOKS_DIRECTORY } from '../../utils/hooks-manager'; +import { pathManager, stateManager, $TSContext, skipHooksFilePath } from 'amplify-cli-core'; +import amplifyCliCore from 'amplify-cli-core'; +import fsExt from 'fs-extra'; +import { S3 } from '../../aws-utils/aws-s3'; +import * as aws from 'aws-sdk'; + +const testProjectRootPath = path.join('testProjectRootPath'); +const testProjectHooksDirPath = path.join(testProjectRootPath, 'testProjectHooksDirPath'); +const testProjectHooksFiles = ['dir1/file1-1', 'dir2/file2-1', 'file1', 'file2', 'hooks-config.json']; + +pathManager.findProjectRoot = jest.fn().mockReturnValue(testProjectRootPath); +pathManager.getHooksDirPath = jest.fn().mockReturnValue(testProjectHooksDirPath); +stateManager.getHooksConfigJson = jest.fn().mockReturnValue({ ignore: ['dir2/', 'file2'] }); + +const bucketName = 'test-bucket'; +const S3ListObjectsMockReturn = { Contents: [{ Key: 'hooks/file1' }, { Key: 'hooks/file2' }, { Key: 'hooks/file3' }] }; + +const awsCredentials = { + accessKeyId: 'TestAmplifyContextAccessKeyId', + secretAccessKey: 'TestAmplifyContextsSecretAccessKey', +}; + +const mockContext = ({ + exeInfo: { + localEnvInfo: { projectPath: testProjectRootPath }, + region: 'TestAmplifyContextRegion', + config: awsCredentials, + inputParams: { + amplify: { + appId: 'TestAmplifyContextAppId', + }, + }, + }, + amplify: { + getEnvInfo: jest.fn().mockReturnValue({ envName: 'test-env' }), + getProjectDetails: jest + .fn() + .mockReturnValue({ teamProviderInfo: { 'test-env': { awscloudformation: { DeploymentBucketName: bucketName } } } }), + }, +} as unknown) as $TSContext; + +let S3_mock_instance: S3; + +const mockS3Instance = { + listObjects: jest.fn().mockReturnValue({ + promise: jest.fn().mockResolvedValue(S3ListObjectsMockReturn), + }), + getObject: jest.fn().mockReturnValue({ + promise: jest.fn().mockResolvedValue({ Body: 'test data' }), + }), + promise: jest.fn().mockReturnThis(), + catch: jest.fn(), +}; + +const istestProjectSubPath = childPath => { + const relativePath = path.relative(testProjectRootPath, childPath); + return relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath); +}; + +jest.mock('process'); +jest.mock('amplify-cli-core', () => ({ ...Object.assign({}, jest.requireActual('amplify-cli-core')) })); +jest.mock('glob', () => { + const actualGlob = jest.requireActual('glob'); + return { + ...Object.assign({}, actualGlob), + sync: jest.fn().mockImplementation((pattern, options) => { + if (testProjectHooksDirPath + '/**/*' === pattern) { + return testProjectHooksFiles.map(filename => path.join(testProjectHooksDirPath, filename)); + } + return actualGlob.sync(pattern, options); + }), + }; +}); +jest.mock('fs-extra', () => { + const actualFs = jest.requireActual('fs-extra'); + return { + ...Object.assign({}, actualFs), + existsSync: jest.fn().mockImplementation(pathStr => { + if (istestProjectSubPath(pathStr)) { + return true; + } + return actualFs.existsSync(pathStr); + }), + lstatSync: jest.fn().mockImplementation(pathStr => { + if (testProjectHooksFiles.includes(path.relative(testProjectHooksDirPath, pathStr))) + return { isFile: jest.fn().mockReturnValue(true) }; + return actualFs.lstatSync(path); + }), + createReadStream: jest.fn().mockImplementation((pathStr, options) => { + if (testProjectHooksFiles.includes(path.relative(testProjectHooksDirPath, pathStr))) { + return 'testdata'; + } + return actualFs.createReadStream(path, options); + }), + writeFileSync: jest.fn(), + ensureFileSync: jest.fn(), + }; +}); +jest.mock('aws-sdk', () => { + return { S3: jest.fn(() => mockS3Instance) }; +}); +let mockSkipHooks = jest.spyOn(amplifyCliCore, 'skipHooks'); +mockSkipHooks.mockImplementation((): boolean => { + return false; +}); + +describe('test hooks-manager ', () => { + beforeEach(async () => { + jest.clearAllMocks(); + + S3_mock_instance = await S3.getInstance(mockContext); + jest.spyOn(S3_mock_instance, 'deleteDirectory').mockImplementation( + (): Promise => { + return; + }, + ); + jest.spyOn(S3_mock_instance, 'uploadFile').mockImplementation( + (): Promise => { + return; + }, + ); + jest.spyOn(S3_mock_instance, 'getAllObjectVersions').mockImplementation( + (): Promise[]> => { + return S3ListObjectsMockReturn.Contents as any; + }, + ); + jest.spyOn(S3_mock_instance, 'getFile').mockImplementation( + (): Promise => { + return 'test data' as any; + }, + ); + }); + afterEach(() => {}); + + test('uploadHooksDirectory test', async () => { + const file1Name = 'file1'; + const file11Name = 'file1-1'; + const dir1Name = 'dir1'; + + await uploadHooksDirectory(mockContext); + expect(S3_mock_instance.deleteDirectory).toHaveBeenCalledTimes(1); + expect(S3_mock_instance.deleteDirectory).toHaveBeenCalledWith(bucketName, S3_HOOKS_DIRECTORY); + expect(S3_mock_instance.uploadFile).toHaveBeenCalledTimes(3); + + expect(S3_mock_instance.uploadFile).toHaveBeenNthCalledWith(1, { + Body: expect.anything(), + Key: S3_HOOKS_DIRECTORY + dir1Name + '/' + file11Name, + }); + expect(S3_mock_instance.uploadFile).toHaveBeenNthCalledWith(2, { + Body: expect.anything(), + Key: S3_HOOKS_DIRECTORY + file1Name, + }); + expect(S3_mock_instance.uploadFile).toHaveBeenNthCalledWith(3, { + Body: expect.anything(), + Key: S3_HOOKS_DIRECTORY + 'hooks-config.json', + }); + }); + + test('downloadHooks test', async () => { + await downloadHooks(mockContext, { deploymentArtifacts: bucketName }, { credentials: awsCredentials }); + + const mockawsS3 = new aws.S3(); + expect(mockawsS3.listObjects).toHaveBeenCalledTimes(1); + expect(mockawsS3.listObjects).toHaveBeenCalledWith({ Prefix: S3_HOOKS_DIRECTORY, Bucket: bucketName }); + expect(mockawsS3.getObject).toHaveBeenCalledTimes(S3ListObjectsMockReturn.Contents.length); + expect(fsExt.writeFileSync).toHaveBeenCalledTimes(3); + expect(fsExt.writeFileSync).toHaveBeenNthCalledWith(1, path.join(testProjectHooksDirPath, 'file1'), expect.anything()); + expect(fsExt.writeFileSync).toHaveBeenNthCalledWith(2, path.join(testProjectHooksDirPath, 'file2'), expect.anything()); + expect(fsExt.writeFileSync).toHaveBeenNthCalledWith(3, path.join(testProjectHooksDirPath, 'file3'), expect.anything()); + }); + + test('pullHooks test', async () => { + await pullHooks(mockContext); + + expect(S3_mock_instance.getAllObjectVersions).toHaveBeenCalledTimes(1); + expect(S3_mock_instance.getAllObjectVersions).toHaveBeenCalledWith(bucketName, { Prefix: S3_HOOKS_DIRECTORY }); + expect(S3_mock_instance.getFile).toHaveBeenCalledTimes(S3ListObjectsMockReturn.Contents.length); + expect(fsExt.writeFileSync).toHaveBeenCalledTimes(3); + expect(fsExt.writeFileSync).toHaveBeenNthCalledWith(1, path.join(testProjectHooksDirPath, 'file1'), expect.anything()); + expect(fsExt.writeFileSync).toHaveBeenNthCalledWith(2, path.join(testProjectHooksDirPath, 'file2'), expect.anything()); + expect(fsExt.writeFileSync).toHaveBeenNthCalledWith(3, path.join(testProjectHooksDirPath, 'file3'), expect.anything()); + }); +}); diff --git a/packages/amplify-provider-awscloudformation/src/attach-backend.js b/packages/amplify-provider-awscloudformation/src/attach-backend.js index e423a526775..f42c4534cea 100644 --- a/packages/amplify-provider-awscloudformation/src/attach-backend.js +++ b/packages/amplify-provider-awscloudformation/src/attach-backend.js @@ -15,6 +15,7 @@ const { resolveAppId } = require('./utils/resolve-appId'); const { adminLoginFlow } = require('./admin-login'); const { fileLogger } = require('./utils/aws-logger'); const logger = fileLogger('attach-backend'); +import { downloadHooks } from './utils/hooks-manager'; async function run(context) { let appId; @@ -75,6 +76,7 @@ async function run(context) { const backendEnv = await getBackendEnv(context, amplifyClient, amplifyApp); await downloadBackend(context, backendEnv, awsConfigInfo); + await downloadHooks(context, backendEnv, awsConfigInfo); const currentAmplifyMeta = await ensureAmplifyMeta(context, amplifyApp, awsConfigInfo); context.exeInfo.projectConfig.projectName = amplifyApp.name; diff --git a/packages/amplify-provider-awscloudformation/src/aws-utils/aws-s3.ts b/packages/amplify-provider-awscloudformation/src/aws-utils/aws-s3.ts index b4536694cda..8bd15e6bb29 100644 --- a/packages/amplify-provider-awscloudformation/src/aws-utils/aws-s3.ts +++ b/packages/amplify-provider-awscloudformation/src/aws-utils/aws-s3.ts @@ -61,6 +61,16 @@ export class S3 { }; } + private attatchBucketToParams(s3Params: $TSAny, envName?: string) { + if (!s3Params.hasOwnProperty('Bucket')) { + const projectDetails = this.context.amplify.getProjectDetails(); + if (!envName) envName = this.context.amplify.getEnvInfo().envName; + const projectBucket = projectDetails.teamProviderInfo[envName][providerName].DeploymentBucketName; + s3Params.Bucket = projectBucket; + } + return s3Params; + } + async uploadFile(s3Params: $TSAny, showSpinner: boolean = true) { // envName and bucket does not change during execution, cache them into a class level // field. @@ -100,10 +110,8 @@ export class S3 { } } - async getFile(s3Params: $TSAny, envName: string = this.context.amplify.getEnvInfo().envName) { - const projectDetails = this.context.amplify.getProjectDetails(); - const projectBucket = projectDetails.teamProviderInfo[envName][providerName].DeploymentBucketName; - s3Params.Bucket = projectBucket; + async getFile(s3Params: $TSAny, envName?: string) { + s3Params = this.attatchBucketToParams(s3Params, envName); const log = logger('s3.getFile', [s3Params]); try { log(); diff --git a/packages/amplify-provider-awscloudformation/src/index.ts b/packages/amplify-provider-awscloudformation/src/index.ts index 0602d926a78..1c5e68714f8 100644 --- a/packages/amplify-provider-awscloudformation/src/index.ts +++ b/packages/amplify-provider-awscloudformation/src/index.ts @@ -34,6 +34,8 @@ export { resolveAppId } from './utils/resolve-appId'; export { loadConfigurationForEnv } from './configuration-manager'; import { updateEnv } from './update-env'; +import { uploadHooksDirectory } from './utils/hooks-manager'; + function init(context) { return initializer.run(context); } @@ -156,4 +158,5 @@ module.exports = { loadConfigurationForEnv, getConfiguredSSMClient, updateEnv, + uploadHooksDirectory, }; diff --git a/packages/amplify-provider-awscloudformation/src/initialize-env.ts b/packages/amplify-provider-awscloudformation/src/initialize-env.ts index 6825d6de3f9..4cc48a989ec 100644 --- a/packages/amplify-provider-awscloudformation/src/initialize-env.ts +++ b/packages/amplify-provider-awscloudformation/src/initialize-env.ts @@ -9,6 +9,7 @@ const { S3BackendZipFileName } = require('./constants'); const { fileLogger } = require('./utils/aws-logger'); const logger = fileLogger('initialize-env'); import { JSONUtilities, PathConstants, stateManager, $TSMeta, $TSContext } from 'amplify-cli-core'; +import { pullHooks } from './utils/hooks-manager'; export async function run(context: $TSContext, providerMetadata: $TSMeta) { if (context.exeInfo && context.exeInfo.isNewEnv) { @@ -56,6 +57,11 @@ export async function run(context: $TSContext, providerMetadata: $TSMeta) { fs.removeSync(tempDir); + // pull hooks directory + if (!context.exeInfo.pushHooks) { + await pullHooks(context); + } + logger('run.cfn.updateamplifyMetaFileWithStackOutputs', [{ StackName: providerMetadata.StackName }])(); await cfnItem.updateamplifyMetaFileWithStackOutputs(providerMetadata.StackName); diff --git a/packages/amplify-provider-awscloudformation/src/initializer.ts b/packages/amplify-provider-awscloudformation/src/initializer.ts index 80bd8fc6d9e..d6c735b02fe 100644 --- a/packages/amplify-provider-awscloudformation/src/initializer.ts +++ b/packages/amplify-provider-awscloudformation/src/initializer.ts @@ -19,6 +19,7 @@ const { fileLogger } = require('./utils/aws-logger'); const { prePushCfnTemplateModifier } = require('./pre-push-cfn-processor/pre-push-cfn-modifier'); const logger = fileLogger('attach-backend'); const { configurePermissionsBoundaryForInit } = require('./permissions-boundary/permissions-boundary'); +const { uploadHooksDirectory } = require('./utils/hooks-manager'); export async function run(context) { await configurationManager.init(context); @@ -166,6 +167,7 @@ export async function onInitSuccessful(context) { if (context.exeInfo.isNewEnv) { context = await storeCurrentCloudBackend(context); await storeArtifactsForAmplifyService(context); + await uploadHooksDirectory(context); } return context; } diff --git a/packages/amplify-provider-awscloudformation/src/utils/hooks-manager.ts b/packages/amplify-provider-awscloudformation/src/utils/hooks-manager.ts new file mode 100644 index 00000000000..889e1f351fd --- /dev/null +++ b/packages/amplify-provider-awscloudformation/src/utils/hooks-manager.ts @@ -0,0 +1,240 @@ +import { $TSAny, $TSContext, pathManager, stateManager, PathConstants, HooksConfig, skipHooks } from 'amplify-cli-core'; +import * as path from 'path'; +import _ from 'lodash'; +import ignore from 'ignore'; +import { S3 } from '../aws-utils/aws-s3'; +import * as aws from 'aws-sdk'; +import * as fs from 'fs-extra'; +import { sync } from 'glob'; +import { ProviderName } from '../constants'; +export const S3_HOOKS_DIRECTORY = 'hooks/'; +import { fileLogger } from '../utils/aws-logger'; +const logger = fileLogger('hooks-manager'); + +/** + * uploads all files except ignored files in hooks directory to S3 bucket + * returns promise that resolves with list of successfully uploaded files + * return void + * throws error if upload to S3 fials + * deletes the previous state of hooks from S3 completely + * + * @param {$TSContext} context + * @returns {Promise} or throws error + */ +export const uploadHooksDirectory = async (context: $TSContext): Promise => { + if (skipHooks()) { + return; + } + const hooksDirectoryPath = pathManager.getHooksDirPath(context.exeInfo?.localEnvInfo?.projectPath); + await deleteHooksFromS3(context); + + if (!fs.existsSync(hooksDirectoryPath)) { + return; + } + + const relativeFilePathsToUpload = getNonIgnoredFileList(context); + const s3 = await S3.getInstance(context); + + for (const relativeFilePathToUpload of relativeFilePathsToUpload) { + const absolutefilePathToUpload = path.join(hooksDirectoryPath, relativeFilePathToUpload); + if (fs.existsSync(absolutefilePathToUpload)) { + const s3Params = { + Body: fs.createReadStream(absolutefilePathToUpload), + Key: getS3Key(relativeFilePathToUpload), + }; + await s3.uploadFile(s3Params); + } + } +}; + +/** + * downloads hooks directory from S3 and places in amplify project. + * used when no amplify project exist. + * + * @param {$TSContext} context + * @param {{ deploymentArtifacts: string }} backendEnv backendEnv object used to get deploymentBucket + * @param {aws.S3.ClientConfiguratio} awsConfigInfo aws credentials information to create S3 object + * @return {Promise} + */ +export const downloadHooks = async ( + context: $TSContext, + backendEnv: { deploymentArtifacts: string }, + awsConfigInfo: aws.S3.ClientConfiguration, +): Promise => { + if (skipHooks()) { + return; + } + if (!backendEnv) { + return; + } + const projectPath = process.cwd(); + const hooksDirPath = pathManager.getHooksDirPath(projectPath); + + const s3 = new aws.S3(awsConfigInfo); + const deploymentBucketName = backendEnv.deploymentArtifacts; + + const params = { + Prefix: S3_HOOKS_DIRECTORY, + Bucket: deploymentBucketName, + }; + + const log = logger('downloadHooks.s3.listObjects', [params]); + let listHookObjects; + try { + log(); + listHookObjects = await s3.listObjects(params).promise(); + } catch (ex) { + log(ex); + throw ex; + } + + // loop over each object in S3 hooks directory and download the file + for (const listHookObject of listHookObjects.Contents) { + const params = { + Key: listHookObject.Key, + Bucket: deploymentBucketName, + }; + + const log = logger('downloadHooks.s3.getObject', [params]); + let hooksFileObject = null; + try { + log(); + hooksFileObject = await s3.getObject(params).promise(); + } catch (ex) { + log(ex); + throw ex; + } + const hooksFilePath = getHooksFilePathFromS3Key(hooksDirPath, listHookObject.Key); + placeFile(hooksFilePath, hooksFileObject.Body); + } +}; + +/** + * pulls hooks directory from S3 and places in amplify project. + * cleans the existing hooks directory. + * + * @param {$TSContext} context + * @return {Promise} + */ +// used by pull-backend +export const pullHooks = async (context: $TSContext): Promise => { + if (skipHooks()) { + return; + } + const projectDetails = context.amplify.getProjectDetails(); + const envName = context.amplify.getEnvInfo().envName; + const projectBucket = projectDetails.teamProviderInfo?.[envName]?.[ProviderName]?.DeploymentBucketName; + const hooksDirPath = pathManager.getHooksDirPath(); + + const s3 = await S3.getInstance(context); + + const listHookObjects = await s3.getAllObjectVersions(projectBucket, { + Prefix: S3_HOOKS_DIRECTORY, + }); + + cleanHooksDirectory(context); + + // loop over each object in S3 hooks directory and download the file + for (const listHookObject of listHookObjects) { + let hooksFileData = null; + hooksFileData = await s3.getFile({ + Key: listHookObject.Key, + }); + + const hooksFilePath = getHooksFilePathFromS3Key(hooksDirPath, listHookObject.Key); + placeFile(hooksFilePath, hooksFileData); + } +}; + +const deleteHooksFromS3 = async (context: $TSContext): Promise => { + const envName: string = context.amplify?.getEnvInfo()?.envName; + const projectDetails = context.amplify?.getProjectDetails(); + const projectBucket: string = projectDetails.teamProviderInfo?.[envName]?.[ProviderName]?.DeploymentBucketName; + + if (!envName || !projectDetails || !projectBucket) { + return; + } + + const s3 = await S3.getInstance(context); + await s3.deleteDirectory(projectBucket, S3_HOOKS_DIRECTORY); +}; + +// hooks utility functions: + +/** + * returns list of relative file paths (no directories) of all files in the hooks directory + * + * @param {$TSContext} context + * @return {string[]} array of relative file paths to hooks directory + */ +const getHooksFilePathList = (context: $TSContext): string[] => { + const hooksDirectoryPath = pathManager.getHooksDirPath(context.exeInfo?.localEnvInfo?.projectPath); + const posixHooksDirectoryPath = convertToPosixPath(hooksDirectoryPath); + + return sync(posixHooksDirectoryPath.concat('/**/*')) + .filter(file => fs.lstatSync(file).isFile()) + .map(file => path.relative(hooksDirectoryPath, file)); +}; + +/** + * returns list of relative file paths (no directories) of all files in the hooks directory except the ones ignored in hooks-config.json + * + * @param {$TSContext} context + * @return {string[]} array of relative file paths to hooks directory + */ +const getNonIgnoredFileList = (context: $TSContext): string[] => { + const ig = ignore(); + const configFile: HooksConfig = stateManager.getHooksConfigJson(context.exeInfo?.localEnvInfo?.projectPath) ?? {}; + if (configFile.ignore) { + ig.add(configFile.ignore); + } + return ig.filter(getHooksFilePathList(context)); +}; + +/** + * returns absolute path to hooks file from s3 key + * + * @param {string} hooksDirPath hooks directory path + * @param {string} s3Key s3 key + * @return {string} absolute path + */ +const getHooksFilePathFromS3Key = (hooksDirPath: string, s3Key: string): string => { + if (s3Key.substring(0, S3_HOOKS_DIRECTORY.length) === S3_HOOKS_DIRECTORY) { + s3Key = s3Key.substring(S3_HOOKS_DIRECTORY.length); + } + + // s3Key has POSIX seperation, split and join to get OS specific path + return path.join(hooksDirPath, ...s3Key.split('/')); +}; + +/** + * removes all files in hooks directory except ignored files + * + * @param {$TSContext} context The number to raise. + * @return {void} + */ +const cleanHooksDirectory = (context: $TSContext): void => { + const relativeFilePathsList = getNonIgnoredFileList(context); + const hooksDirectoryPath = pathManager.getHooksDirPath(context.exeInfo?.localEnvInfo?.projectPath); + for (const relativeFilePath of relativeFilePathsList) { + const absolutefilePathToUpload = path.join(hooksDirectoryPath, relativeFilePath); + if (fs.lstatSync(absolutefilePathToUpload).isFile() && relativeFilePath !== PathConstants.HooksConfigFileName) { + fs.removeSync(absolutefilePathToUpload); + } + } +}; + +// general utility functions: + +const placeFile = (filePath: string, data: $TSAny): void => { + fs.ensureFileSync(filePath); + fs.writeFileSync(filePath, data); +}; + +const convertToPosixPath = (filePath: string): string => { + return filePath.split(path.sep).join(path.posix.sep); +}; + +const getS3Key = (relativePath: string): string => { + return S3_HOOKS_DIRECTORY + convertToPosixPath(relativePath); +};