diff --git a/README.md b/README.md index 1cf738a8..31bc3098 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,98 @@ input of the second: cluster: my-cluster ``` +If containers in your task definition require different values depending on environment, you can specify a `merge` file that contains a JSON fragment to merge with the `task-definition`. `merge` task defintion JSON fragments can be used to modify any key/value pair in `task-definition`. If merging an array value, arrays from the `task-defition` and `merge` fragment will be concatenated. + +`containerDefintions` and `name` within each container definition are required. + +_task-def.json_ + +```json + { + "family": "task-def-family", + "containerDefinitions": [ + { + "name": "web", + "image": "some-image" + } + ] + } +``` + +_staging-vars.json_ + +```json + { + "containerDefinitions": [ + { + "name": "web", + "environment": [ + { + "name": "log_level", + "value": "debug" + } + ] + } + ] + } +``` + +_prod-vars.json_ + +```json + { + "containerDefinitions": [ + { + "name": "web", + "environment": [ + { + "name": "log_level", + "value": "info" + } + ] + } + ] + } +``` + +```yaml + - name: Add image to Amazon ECS task definition + id: render-image-in-task-def + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: task-def.json + container-name: web + image: amazon/amazon-ecs-sample:latest + + - name: Render Amazon ECS task definition for staging + id: render-staging-task-def + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: ${{ steps.render-image-in-task-def.outputs.task-definition }} + merge: staging-vars.json + + - name: Render Amazon ECS task definition for prod + id: render-prod-task-def + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: ${{ steps.render-image-in-task-def.outputs.task-definition }} + merge: prod-vars.json + + - name: Deploy to Staging + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: ${{ steps.render-staging-task-def.outputs.task-definition }} + service: my-staging-service + cluster: my-staging-cluster + + - name: Deploy to Prod + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: ${{ steps.render-prod-task-def.outputs.task-definition }} + service: my-prod-service + cluster: my-prod-cluster +``` + See [action.yml](action.yml) for the full documentation for this action's inputs and outputs. ## License Summary diff --git a/action.yml b/action.yml index 5f6ae11a..f91f2e6d 100644 --- a/action.yml +++ b/action.yml @@ -12,7 +12,10 @@ inputs: required: true image: description: 'The URI of the container image to insert into the ECS task definition' - required: true + required: false + merge: + description: 'The path to a task definition JSON fragment file to merge with the task defintion' + required: false outputs: task-definition: description: 'The path to the rendered task definition file' diff --git a/index.js b/index.js index ec9b78c8..eca2e240 100644 --- a/index.js +++ b/index.js @@ -2,13 +2,26 @@ const path = require('path'); const core = require('@actions/core'); const tmp = require('tmp'); const fs = require('fs'); +const mergeWith = require('lodash.mergewith'); + +// Customizer for lodash mergeWith +// allows arrays in the original task definition to contain +// values as opposed to only in the mergeFiles (otherwise +// they are overridden) +// https://lodash.com/docs/4.17.15#mergeWith +function customizer(objValue, srcValue) { + if (Array.isArray(objValue)) { + return objValue.concat(srcValue); + } +} async function run() { try { // Get inputs const taskDefinitionFile = core.getInput('task-definition', { required: true }); const containerName = core.getInput('container-name', { required: true }); - const imageURI = core.getInput('image', { required: true }); + const imageURI = core.getInput('image', { required: false }); + const mergeFile = core.getInput('merge', { required: false }); // Parse the task definition const taskDefPath = path.isAbsolute(taskDefinitionFile) ? @@ -19,7 +32,7 @@ async function run() { } const taskDefContents = require(taskDefPath); - // Insert the image URI + // Get containerDef with name `containerName` if (!Array.isArray(taskDefContents.containerDefinitions)) { throw new Error('Invalid task definition format: containerDefinitions section is not present or is not an array'); } @@ -29,7 +42,38 @@ async function run() { if (!containerDef) { throw new Error('Invalid task definition: Could not find container definition with matching name'); } - containerDef.image = imageURI; + + // Check for imageURI + if(imageURI) { + // Insert the image URI + containerDef.image = imageURI; + } + + // Check for mergeFile + if (mergeFile) { + // Parse the merge file + const mergeFilePath = path.isAbsolute(mergeFile) ? + mergeFile : + path.join(process.env.GITHUB_WORKSPACE, mergeFile); + if (!fs.existsSync(mergeFilePath)) { + throw new Error(`Merge file does not exist: ${mergeFile}`); + } + const mergeContents = require(mergeFilePath); + + // Merge the merge file + if (!Array.isArray(mergeContents.containerDefinitions)) { + throw new Error('Invalid merge fragment definition: containerDefinitions section is not present or is not an array'); + } + const mergeDef = mergeContents.containerDefinitions.find(function(element) { + return element.name == containerName; + }); + if (!mergeDef) { + throw new Error('Invalid merge fragment definition: Could not find container definition with matching name'); + } + + // mergeWith contents + mergeWith(containerDef, mergeDef, customizer); + } // Write out a new task definition file var updatedTaskDefFile = tmp.fileSync({ diff --git a/index.test.js b/index.test.js index 379d5706..461ca3ad 100644 --- a/index.test.js +++ b/index.test.js @@ -108,6 +108,177 @@ describe('Render task definition', () => { expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition', 'new-task-def-file-name'); }); + test('renders a task definition without an image specificied', async () => { + core.getInput = jest + .fn() + .mockReturnValueOnce('task-definition-no-image.json') + .mockReturnValueOnce('web') + .mockReturnValueOnce(undefined) + .mockReturnValueOnce('merge-no-image.json'); + + jest.mock('./task-definition-no-image.json', () => ({ + family: 'task-def-family', + containerDefinitions: [ + { + name: "web", + image: "nginx:latest" + } + ] + }), { virtual: true }); + + jest.mock('./merge-no-image.json', () => ({ + containerDefinitions: [ + { + name: "web", + environment: [ + { + name: "log_level", + value: "info" + } + ] + } + ] + }), {virtual: true}); + + await run(); + + expect(fs.writeFileSync).toHaveBeenNthCalledWith(1, 'new-task-def-file-name', + JSON.stringify({ + family: 'task-def-family', + containerDefinitions: [ + { + name: "web", + image: "nginx:latest", + environment: [ + { + name: "log_level", + value: "info" + } + ] + } + ] + }, null, 2) + ); + }); + + test('renders a task definition with a merge file', async () => { + core.getInput = jest + .fn() + .mockReturnValueOnce('task-definition.json') // task-definition + .mockReturnValueOnce('web') // container-name + .mockReturnValueOnce('nginx:latest') // image + .mockReturnValueOnce('merge.json'); // merge + + jest.mock('./merge.json', () => ({ + containerDefinitions: [ + { + name: "web", + environment: [ + { + name: "log_level", + value: "info" + } + ] + } + ] + }), {virtual: true}); + + await run(); + + expect(fs.writeFileSync).toHaveBeenNthCalledWith(1, 'new-task-def-file-name', + JSON.stringify({ + family: 'task-def-family', + containerDefinitions: [ + { + name: "web", + image: "nginx:latest", + environment: [ + { + name: "log_level", + value: "info" + } + ] + }, + { + name: "sidecar", + image: "hello" + } + ] + }, null, 2) + ); + }); + + test('renders a task definition with a merge file to merge an array value', async () => { + core.getInput = jest + .fn() + .mockReturnValueOnce('task-definition.json') // task-definition + .mockReturnValueOnce('web') // container-name + .mockReturnValueOnce('nginx:latest') // image + .mockReturnValueOnce('merge-to-existing.json'); // merge + + jest.mock('./merge-to-existing.json', () => ({ + containerDefinitions: [ + { + name: "web", + environment: [ + { + name: "env", + value: "prod" + } + ] + } + ] + }), {virtual: true}); + + jest.mock('./task-definition.json', () => ({ + family: 'task-def-family', + containerDefinitions: [ + { + name: "web", + image: "some-other-image", + environment: [ + { + name: "log_level", + value: "info" + } + ] + }, + { + name: "sidecar", + image: "hello" + } + ] + }), { virtual: true }); + + await run(); + + expect(fs.writeFileSync).toHaveBeenNthCalledWith(1, 'new-task-def-file-name', + JSON.stringify({ + family: 'task-def-family', + containerDefinitions: [ + { + name: "web", + image: "nginx:latest", + environment: [ + { + name: "log_level", + value: "info" + }, + { + name: "env", + value: "prod" + } + ] + }, + { + name: "sidecar", + image: "hello" + } + ] + }, null, 2) + ); + }); + test('error returned for missing task definition file', async () => { fs.existsSync.mockReturnValue(false); core.getInput = jest @@ -173,4 +344,32 @@ describe('Render task definition', () => { expect(core.setFailed).toBeCalledWith('Invalid task definition: Could not find container definition with matching name'); }); + + test('error returned for invalid merge file', async () => { + fs.existsSync.mockReturnValue(false); + core.getInput = jest + .fn() + .mockReturnValueOnce('task-definition.json') + .mockReturnValueOnce('web') + .mockReturnValueOnce('nginx:latest') + .mockReturnValueOnce('invalid-merge-file.json'); + + fs.existsSync.mockReturnValueOnce(JSON.stringify({ + family: 'task-def-family', + containerDefinitions: [ + { + name: "web", + image: "nginx:latest" + }, + { + name: "sidecar", + image: "hello" + } + ] + })); + + await run(); + + expect(core.setFailed).toBeCalledWith('Merge file does not exist: invalid-merge-file.json'); + }); }); diff --git a/package-lock.json b/package-lock.json index 3bf23519..56d338cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4546,6 +4546,11 @@ "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", "dev": true }, + "lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==" + }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", diff --git a/package.json b/package.json index 27173492..6483d36e 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "homepage": "https://github.com/aws-actions/amazon-ecs-render-task-definition#readme", "dependencies": { "@actions/core": "^1.2.6", + "lodash.mergewith": "^4.6.2", "tmp": "^0.2.1" }, "devDependencies": {