diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index a37dfac04..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,50 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. - -### [1.3.5](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/compare/v1.3.4...v1.3.5) (2020-06-30) - -### [1.3.4](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/compare/v1.3.3...v1.3.4) (2020-06-09) - -### [1.3.3](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/compare/v1.3.2...v1.3.3) (2020-05-27) - -### [1.3.2](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/compare/v1.3.1...v1.3.2) (2020-05-18) - -### [1.3.1](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/compare/v1.3.0...v1.3.1) (2020-05-08) - - -### Bug Fixes - -* clean null values out of arrays ([#63](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/issues/63)) ([6b1f3e4](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/commit/6b1f3e4e8c4e9b191fbf70a5c79418b7eaa995a9)) - -## [1.3.0](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/compare/v1.2.0...v1.3.0) (2020-04-22) - - -### Features - -* Add more debugging, including link to the ECS or CodeDeploy console ([#56](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/issues/56)) ([f0b3966](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/commit/f0b3966cfef41a73fc35f3001025fb9290b3673b)) - -## [1.2.0](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/compare/v1.1.0...v1.2.0) (2020-04-02) - - -### Features - -* clean empty arrays and objects from the task def file ([#52](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/issues/52)) ([e64c8a6](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/commit/e64c8a6fd7cb8f40b6487fc0acd0a357cc1eaffd)) - -## [1.1.0](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/compare/v1.0.3...v1.1.0) (2020-03-05) - - -### Features - -* add option to specify number of minutes to wait for deployment to complete ([#37](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/issues/37)) ([27c64c3](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/commit/27c64c3fabb355c8a4311a02eaf507f684adc033)), closes [#33](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/issues/33) - -### [1.0.3](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/compare/v1.0.2...v1.0.3) (2020-02-06) - -### [1.0.2](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/compare/v1.0.1...v1.0.2) (2020-02-06) - - -### Bug Fixes - -* Ignore task definition fields that are Describe outputs, but not Register inputs ([70d7e5a](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/commit/70d7e5a70a160768b612a0d0db2820fb24259958)), closes [#22](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/issues/22) -* Match package version to current tag version ([2c12fa8](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/commit/2c12fa8bf9f89ea322d319c83cfcf8f3175bfbb1)) -* Reduce error debugging ([7a9b7f7](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/commit/7a9b7f71e4f9b87151c1b4e3bde474db2eee1595)) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 5b627cfa6..000000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,4 +0,0 @@ -## Code of Conduct -This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). -For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact -opensource-codeofconduct@amazon.com with any additional questions or comments. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 936e2aafc..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,61 +0,0 @@ -# Contributing Guidelines - -Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional -documentation, we greatly value feedback and contributions from our community. - -Please read through this document before submitting any issues or pull requests to ensure we have all the necessary -information to effectively respond to your bug report or contribution. - - -## Reporting Bugs/Feature Requests - -We welcome you to use the GitHub issue tracker to report bugs or suggest features. - -When filing an issue, please check [existing open](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/issues), or [recently closed](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already -reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: - -* A reproducible test case or series of steps -* The version of our code being used -* Any modifications you've made relevant to the bug -* Anything unusual about your environment or deployment - - -## Contributing via Pull Requests -Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: - -1. You are working against the latest source on the *master* branch. -2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. -3. You open an issue to discuss any significant work - we would hate for your time to be wasted. - -To send us a pull request, please: - -1. Fork the repository. -2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. -3. Ensure local tests pass. -4. Commit to your fork using clear commit messages. -5. Send us a pull request, answering any default questions in the pull request interface. -6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. - -GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and -[creating a pull request](https://help.github.com/articles/creating-a-pull-request/). - - -## Finding contributions to work on -Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/labels/help%20wanted) issues is a great place to start. - - -## Code of Conduct -This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). -For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact -opensource-codeofconduct@amazon.com with any additional questions or comments. - - -## Security issue notifications -If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. - - -## Licensing - -See the [LICENSE](https://github.com/aws-actions/amazon-ecs-deploy-task-definition/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. - -We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. diff --git a/LICENSE b/LICENSE index 1f7884179..0fd1f4364 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright 2019 Amazon.com, Inc. or its affiliates. +Copyright (c) 2020 Smit Patel Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 058fd507f..f8d792658 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,33 @@ -## Amazon ECS "Deploy Task Definition" Action for GitHub Actions +## Amazon ECS "Run Task" Action for GitHub Actions -Registers an Amazon ECS task definition and deploys it to an ECS service. +Runs an Amazon ECS task on ECS cluster. **Table of Contents** +- [Amazon ECS "Run Task" Action for GitHub Actions](#amazon-ecs-run-task-action-for-github-actions) - [Usage](#usage) - + [Task definition file](#task-definition-file) - + [Task definition container image values](#task-definition-container-image-values) + - [Task definition file](#task-definition-file) + - [Task definition container image values](#task-definition-container-image-values) - [Credentials and Region](#credentials-and-region) - [Permissions](#permissions) -- [AWS CodeDeploy Support](#aws-codedeploy-support) - [Troubleshooting](#troubleshooting) - [License Summary](#license-summary) -- [Security Disclosures](#security-disclosures) ## Usage ```yaml - - name: Deploy to Amazon ECS - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + - name: Run Task on Amazon ECS + uses: smitp/amazon-ecs-run-task@v1 with: task-definition: task-definition.json - service: my-service cluster: my-cluster - wait-for-service-stability: true + count: 1 + started-by: github-actions-${{ github.actor }} + wait-for-finish: true ``` See [action.yml](action.yml) for the full documentation for this action's inputs and outputs. @@ -93,13 +93,14 @@ The task definition file can be updated prior to deployment with the new contain container-name: my-container image: ${{ steps.build-image.outputs.image }} - - name: Deploy Amazon ECS task definition - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + - name: Run Task on Amazon ECS + uses: smitp/amazon-ecs-run-task@v1 with: - task-definition: ${{ steps.task-def.outputs.task-definition }} - service: my-service + task-definition: task-definition.json cluster: my-cluster - wait-for-service-stability: true + count: 1 + started-by: github-actions-${{ github.actor }} + wait-for-finish: true ``` ## Credentials and Region @@ -142,86 +143,23 @@ This action requires the following minimum set of permissions: ] }, { - "Sid":"DeployService", - "Effect":"Allow", - "Action":[ - "ecs:UpdateService", - "ecs:DescribeServices" - ], - "Resource":[ - "arn:aws:ecs:::service//" - ] - } - ] -} -``` - -Note: the policy above assumes the account has opted in to the ECS long ARN format. - -## AWS CodeDeploy Support - -For ECS services that uses the `CODE_DEPLOY` deployment controller, additional configuration is needed for this action: - -```yaml - - name: Deploy to Amazon ECS - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 - with: - task-definition: task-definition.json - service: my-service - cluster: my-cluster - wait-for-service-stability: true - codedeploy-appspec: appspec.json - codedeploy-application: my-codedeploy-application - codedeploy-deployment-group: my-codedeploy-deployment-group -``` - -The minimal permissions require access to CodeDeploy: - -```json -{ - "Version":"2012-10-17", - "Statement":[ - { - "Sid":"RegisterTaskDefinition", - "Effect":"Allow", - "Action":[ - "ecs:RegisterTaskDefinition" - ], - "Resource":"*" - }, - { - "Sid":"PassRolesInTaskDefinition", - "Effect":"Allow", - "Action":[ - "iam:PassRole" - ], - "Resource":[ - "arn:aws:iam:::role/", - "arn:aws:iam:::role/" - ] + "Sid": "RunTask", + "Effect": "Allow", + "Action": "ecs:RunTask", + "Resource": "arn:aws:ecs:::task-definition/*:*" }, { - "Sid":"DeployService", - "Effect":"Allow", - "Action":[ - "ecs:DescribeServices", - "codedeploy:GetDeploymentGroup", - "codedeploy:CreateDeployment", - "codedeploy:GetDeployment", - "codedeploy:GetDeploymentConfig", - "codedeploy:RegisterApplicationRevision" - ], - "Resource":[ - "arn:aws:ecs:::service//", - "arn:aws:codedeploy:::deploymentgroup:/", - "arn:aws:codedeploy:::deploymentconfig:*", - "arn:aws:codedeploy:::application:" - ] + "Sid": "DescribeTasks", + "Effect": "Allow", + "Action": "ecs:DescribeTasks", + "Resource": "arn:aws:ecs:::task/*" } ] } ``` +Note: the policy above assumes the account has opted in to the ECS long ARN format. + ## Troubleshooting This action emits debug logs to help troubleshoot deployment failures. To see the debug logs, create a secret named `ACTIONS_STEP_DEBUG` with value `true` in your repository. @@ -229,7 +167,3 @@ This action emits debug logs to help troubleshoot deployment failures. To see t ## License Summary This code is made available under the MIT license. - -## Security Disclosures - -If you would like to report a potential security issue in this project, please do not create a GitHub issue. Instead, please follow the instructions [here](https://aws.amazon.com/security/vulnerability-reporting/) or [email AWS security directly](mailto:aws-security@amazon.com). diff --git a/action.yml b/action.yml index 72d715b81..424dde637 100644 --- a/action.yml +++ b/action.yml @@ -1,38 +1,32 @@ -name: 'Amazon ECS "Deploy Task Definition" Action for GitHub Actions' -description: 'Registers an Amazon ECS task definition, and deploys it to an ECS service' +name: 'Amazon ECS "Run Task" Action for GitHub Actions' +description: 'Runs an Amazon ECS task' branding: icon: 'cloud' color: 'orange' inputs: task-definition: - description: 'The path to the ECS task definition file to register' + description: 'The name of ECS task definition' required: true - service: - description: 'The name of the ECS service to deploy to. The action will only register the task definition if no service is given.' - required: false cluster: - description: "The name of the ECS service's cluster. Will default to the 'default' cluster" + description: "The name of the ECS cluster. Will default to the 'default' cluster" + required: true + count: + description: "The count of tasks to run. Will default to the 1" + required: true + started-by: + description: "The value of the task started-by" required: false - wait-for-service-stability: - description: 'Whether to wait for the ECS service to reach stable state after deploying the new task definition. Valid value is "true". Will default to not waiting.' + wait-for-finish: + description: "Whether to wait for tasks to reach stopped state. Will default to not waiting" required: false wait-for-minutes: - description: 'How long to wait for the ECS service to reach stable state, in minutes (default: 30 minutes, max: 6 hours). For CodeDeploy deployments, any wait time configured in the CodeDeploy deployment group will be added to this value.' - required: false - codedeploy-appspec: - description: "The path to the AWS CodeDeploy AppSpec file, if the ECS service uses the CODE_DEPLOY deployment controller. Will default to 'appspec.yaml'." - required: false - codedeploy-application: - description: "The name of the AWS CodeDeploy application, if the ECS service uses the CODE_DEPLOY deployment controller. Will default to 'AppECS-{cluster}-{service}'." - required: false - codedeploy-deployment-group: - description: "The name of the AWS CodeDeploy deployment group, if the ECS service uses the CODE_DEPLOY deployment controller. Will default to 'DgpECS-{cluster}-{service}'." + description: 'How long to wait for the task reach stopped state, in minutes (default: 30 minutes, max: 6 hours).' required: false outputs: task-definition-arn: description: 'The ARN of the registered ECS task definition' - codedeploy-deployment-id: - description: 'The deployment ID of the CodeDeploy deployment (if the ECS service uses the CODE_DEPLOY deployment controller' + task-arn: + description: 'The ARN of the ECS task' runs: using: 'node12' main: 'dist/index.js' diff --git a/dist/index.js b/dist/index.js index 7b77f3af2..9c182ed67 100644 --- a/dist/index.js +++ b/dist/index.js @@ -136,10 +136,6 @@ const core = __webpack_require__(6470); const aws = __webpack_require__(9350); const yaml = __webpack_require__(521); const fs = __webpack_require__(5747); -const crypto = __webpack_require__(6417); - -const MAX_WAIT_MINUTES = 360; // 6 hours -const WAIT_DEFAULT_DELAY_SEC = 15; // Attributes that are returned by DescribeTaskDefinition, but are not valid RegisterTaskDefinition inputs const IGNORED_TASK_DEFINITION_ATTRIBUTES = [ @@ -150,53 +146,8 @@ const IGNORED_TASK_DEFINITION_ATTRIBUTES = [ 'status' ]; -// Deploy to a service that uses the 'ECS' deployment controller -async function updateEcsService(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes) { - core.debug('Updating the service'); - await ecs.updateService({ - cluster: clusterName, - service: service, - taskDefinition: taskDefArn - }).promise(); - core.info(`Deployment started. Watch this deployment's progress in the Amazon ECS console: https://console.aws.amazon.com/ecs/home?region=${aws.config.region}#/clusters/${clusterName}/services/${service}/events`); - - // Wait for service stability - if (waitForService && waitForService.toLowerCase() === 'true') { - core.debug(`Waiting for the service to become stable. Will wait for ${waitForMinutes} minutes`); - const maxAttempts = (waitForMinutes * 60) / WAIT_DEFAULT_DELAY_SEC; - await ecs.waitFor('servicesStable', { - services: [service], - cluster: clusterName, - $waiter: { - delay: WAIT_DEFAULT_DELAY_SEC, - maxAttempts: maxAttempts - } - }).promise(); - } else { - core.debug('Not waiting for the service to become stable'); - } -} - -// Find value in a CodeDeploy AppSpec file with a case-insensitive key -function findAppSpecValue(obj, keyName) { - return obj[findAppSpecKey(obj, keyName)]; -} - -function findAppSpecKey(obj, keyName) { - if (!obj) { - throw new Error(`AppSpec file must include property '${keyName}'`); - } - - const keyToMatch = keyName.toLowerCase(); - - for (var key in obj) { - if (key.toLowerCase() == keyToMatch) { - return key; - } - } - - throw new Error(`AppSpec file must include property '${keyName}'`); -} +const WAIT_DEFAULT_DELAY_SEC = 5; +const MAX_WAIT_MINUTES = 360; function isEmptyValue(value) { if (value === null || value === undefined || value === '') { @@ -258,98 +209,20 @@ function removeIgnoredAttributes(taskDef) { return taskDef; } -// Deploy to a service that uses the 'CODE_DEPLOY' deployment controller -async function createCodeDeployDeployment(codedeploy, clusterName, service, taskDefArn, waitForService, waitForMinutes) { - core.debug('Updating AppSpec file with new task definition ARN'); - - let codeDeployAppSpecFile = core.getInput('codedeploy-appspec', { required : false }); - codeDeployAppSpecFile = codeDeployAppSpecFile ? codeDeployAppSpecFile : 'appspec.yaml'; - - let codeDeployApp = core.getInput('codedeploy-application', { required: false }); - codeDeployApp = codeDeployApp ? codeDeployApp : `AppECS-${clusterName}-${service}`; - - let codeDeployGroup = core.getInput('codedeploy-deployment-group', { required: false }); - codeDeployGroup = codeDeployGroup ? codeDeployGroup : `DgpECS-${clusterName}-${service}`; - - let deploymentGroupDetails = await codedeploy.getDeploymentGroup({ - applicationName: codeDeployApp, - deploymentGroupName: codeDeployGroup - }).promise(); - deploymentGroupDetails = deploymentGroupDetails.deploymentGroupInfo; - - // Insert the task def ARN into the appspec file - const appSpecPath = path.isAbsolute(codeDeployAppSpecFile) ? - codeDeployAppSpecFile : - path.join(process.env.GITHUB_WORKSPACE, codeDeployAppSpecFile); - const fileContents = fs.readFileSync(appSpecPath, 'utf8'); - const appSpecContents = yaml.parse(fileContents); - - for (var resource of findAppSpecValue(appSpecContents, 'resources')) { - for (var name in resource) { - const resourceContents = resource[name]; - const properties = findAppSpecValue(resourceContents, 'properties'); - const taskDefKey = findAppSpecKey(properties, 'taskDefinition'); - properties[taskDefKey] = taskDefArn; - } - } - - const appSpecString = JSON.stringify(appSpecContents); - const appSpecHash = crypto.createHash('sha256').update(appSpecString).digest('hex'); - - // Start the deployment with the updated appspec contents - core.debug('Starting CodeDeploy deployment'); - const createDeployResponse = await codedeploy.createDeployment({ - applicationName: codeDeployApp, - deploymentGroupName: codeDeployGroup, - revision: { - revisionType: 'AppSpecContent', - appSpecContent: { - content: appSpecString, - sha256: appSpecHash - } - } - }).promise(); - core.setOutput('codedeploy-deployment-id', createDeployResponse.deploymentId); - core.info(`Deployment started. Watch this deployment's progress in the AWS CodeDeploy console: https://console.aws.amazon.com/codesuite/codedeploy/deployments/${createDeployResponse.deploymentId}?region=${aws.config.region}`); - - // Wait for deployment to complete - if (waitForService && waitForService.toLowerCase() === 'true') { - // Determine wait time - const deployReadyWaitMin = deploymentGroupDetails.blueGreenDeploymentConfiguration.deploymentReadyOption.waitTimeInMinutes; - const terminationWaitMin = deploymentGroupDetails.blueGreenDeploymentConfiguration.terminateBlueInstancesOnDeploymentSuccess.terminationWaitTimeInMinutes; - let totalWaitMin = deployReadyWaitMin + terminationWaitMin + waitForMinutes; - if (totalWaitMin > MAX_WAIT_MINUTES) { - totalWaitMin = MAX_WAIT_MINUTES; - } - const maxAttempts = (totalWaitMin * 60) / WAIT_DEFAULT_DELAY_SEC; - - core.debug(`Waiting for the deployment to complete. Will wait for ${totalWaitMin} minutes`); - await codedeploy.waitFor('deploymentSuccessful', { - deploymentId: createDeployResponse.deploymentId, - $waiter: { - delay: WAIT_DEFAULT_DELAY_SEC, - maxAttempts: maxAttempts - } - }).promise(); - } else { - core.debug('Not waiting for the deployment to complete'); - } -} - async function run() { try { + const agent = 'amazon-ecs-run-task-for-github-actions' + const ecs = new aws.ECS({ - customUserAgent: 'amazon-ecs-deploy-task-definition-for-github-actions' - }); - const codedeploy = new aws.CodeDeploy({ - customUserAgent: 'amazon-ecs-deploy-task-definition-for-github-actions' + customUserAgent: agent }); // Get inputs const taskDefinitionFile = core.getInput('task-definition', { required: true }); - const service = core.getInput('service', { required: false }); const cluster = core.getInput('cluster', { required: false }); - const waitForService = core.getInput('wait-for-service-stability', { required: false }); + const count = core.getInput('count', { required: true }); + const startedBy = core.getInput('started-by', { required: false }) || agent; + const waitForFinish = core.getInput('wait-for-finish', { required: false }) || false; let waitForMinutes = parseInt(core.getInput('wait-for-minutes', { required: false })) || 30; if (waitForMinutes > MAX_WAIT_MINUTES) { waitForMinutes = MAX_WAIT_MINUTES; @@ -362,6 +235,7 @@ async function run() { path.join(process.env.GITHUB_WORKSPACE, taskDefinitionFile); const fileContents = fs.readFileSync(taskDefPath, 'utf8'); const taskDefContents = removeIgnoredAttributes(cleanNullKeys(yaml.parse(fileContents))); + let registerResponse; try { registerResponse = await ecs.registerTaskDefinition(taskDefContents).promise(); @@ -374,37 +248,36 @@ async function run() { const taskDefArn = registerResponse.taskDefinition.taskDefinitionArn; core.setOutput('task-definition-arn', taskDefArn); - // Update the service with the new task definition - if (service) { - const clusterName = cluster ? cluster : 'default'; + const clusterName = cluster ? cluster : 'default'; - // Determine the deployment controller - const describeResponse = await ecs.describeServices({ - services: [service], - cluster: clusterName - }).promise(); + core.debug(`Running task with ${JSON.stringify({ + cluster: clusterName, + taskDefinition: taskDefArn, + count: count, + startedBy: startedBy + })}`) - if (describeResponse.failures && describeResponse.failures.length > 0) { - const failure = describeResponse.failures[0]; - throw new Error(`${failure.arn} is ${failure.reason}`); - } + const runTaskResponse = await ecs.runTask({ + cluster: clusterName, + taskDefinition: taskDefArn, + count: count, + startedBy: startedBy + }).promise(); - const serviceResponse = describeResponse.services[0]; - if (serviceResponse.status != 'ACTIVE') { - throw new Error(`Service is ${serviceResponse.status}`); - } + core.debug(`Run task response ${JSON.stringify(runTaskResponse)}`) - if (!serviceResponse.deploymentController) { - // Service uses the 'ECS' deployment controller, so we can call UpdateService - await updateEcsService(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes); - } else if (serviceResponse.deploymentController.type == 'CODE_DEPLOY') { - // Service uses CodeDeploy, so we should start a CodeDeploy deployment - await createCodeDeployDeployment(codedeploy, clusterName, service, taskDefArn, waitForService, waitForMinutes); - } else { - throw new Error(`Unsupported deployment controller: ${serviceResponse.deploymentController.type}`); - } - } else { - core.debug('Service was not specified, no service updated'); + if (runTaskResponse.failures && runTaskResponse.failures.length > 0) { + const failure = runTaskResponse.failures[0]; + throw new Error(`${failure.arn} is ${failure.reason}`); + } + + const taskArns = runTaskResponse.tasks.map(task => task.taskArn); + + core.setOutput('task-arn', taskArns); + + if (waitForFinish && waitForFinish.toLowerCase() === 'true') { + await waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes); + await tasksExitCode(ecs, clusterName, taskArns); } } catch (error) { @@ -413,6 +286,56 @@ async function run() { } } +async function waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes) { + if (waitForMinutes > MAX_WAIT_MINUTES) { + waitForMinutes = MAX_WAIT_MINUTES; + } + + const maxAttempts = (waitForMinutes * 60) / WAIT_DEFAULT_DELAY_SEC; + + core.debug('Waiting for tasks to stop'); + + const waitTaskResponse = await ecs.waitFor('tasksStopped', { + cluster: clusterName, + tasks: taskArns, + $waiter: { + delay: WAIT_DEFAULT_DELAY_SEC, + maxAttempts: maxAttempts + } + }).promise(); + + core.debug(`Run task response ${JSON.stringify(waitTaskResponse)}`) + + core.info(`All tasks have stopped. Watch progress in the Amazon ECS console: https://console.aws.amazon.com/ecs/home?region=${aws.config.region}#/clusters/${clusterName}/tasks`); +} + +async function tasksExitCode(ecs, clusterName, taskArns) { + const describeResponse = await ecs.describeTasks({ + cluster: clusterName, + tasks: taskArns + }).promise(); + + const containers = [].concat(...describeResponse.tasks.map(task => task.containers)) + const exitCodes = containers.map(container => container.exitCode) + const reasons = containers.map(container => container.reason) + + const failuresIdx = []; + + exitCodes.filter((exitCode, index) => { + if (exitCode !== 0) { + failuresIdx.push(index) + } + }) + + const failures = reasons.filter((_, index) => failuresIdx.indexOf(index) !== -1) + + if (failures.length > 0) { + core.setFailed(failures.join("\n")); + } else { + core.info(`All tasks have exited successfully.`); + } +} + module.exports = run; /* istanbul ignore next */ diff --git a/index.js b/index.js index 811c6b200..b07b6f216 100644 --- a/index.js +++ b/index.js @@ -3,10 +3,6 @@ const core = require('@actions/core'); const aws = require('aws-sdk'); const yaml = require('yaml'); const fs = require('fs'); -const crypto = require('crypto'); - -const MAX_WAIT_MINUTES = 360; // 6 hours -const WAIT_DEFAULT_DELAY_SEC = 15; // Attributes that are returned by DescribeTaskDefinition, but are not valid RegisterTaskDefinition inputs const IGNORED_TASK_DEFINITION_ATTRIBUTES = [ @@ -17,53 +13,8 @@ const IGNORED_TASK_DEFINITION_ATTRIBUTES = [ 'status' ]; -// Deploy to a service that uses the 'ECS' deployment controller -async function updateEcsService(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes) { - core.debug('Updating the service'); - await ecs.updateService({ - cluster: clusterName, - service: service, - taskDefinition: taskDefArn - }).promise(); - core.info(`Deployment started. Watch this deployment's progress in the Amazon ECS console: https://console.aws.amazon.com/ecs/home?region=${aws.config.region}#/clusters/${clusterName}/services/${service}/events`); - - // Wait for service stability - if (waitForService && waitForService.toLowerCase() === 'true') { - core.debug(`Waiting for the service to become stable. Will wait for ${waitForMinutes} minutes`); - const maxAttempts = (waitForMinutes * 60) / WAIT_DEFAULT_DELAY_SEC; - await ecs.waitFor('servicesStable', { - services: [service], - cluster: clusterName, - $waiter: { - delay: WAIT_DEFAULT_DELAY_SEC, - maxAttempts: maxAttempts - } - }).promise(); - } else { - core.debug('Not waiting for the service to become stable'); - } -} - -// Find value in a CodeDeploy AppSpec file with a case-insensitive key -function findAppSpecValue(obj, keyName) { - return obj[findAppSpecKey(obj, keyName)]; -} - -function findAppSpecKey(obj, keyName) { - if (!obj) { - throw new Error(`AppSpec file must include property '${keyName}'`); - } - - const keyToMatch = keyName.toLowerCase(); - - for (var key in obj) { - if (key.toLowerCase() == keyToMatch) { - return key; - } - } - - throw new Error(`AppSpec file must include property '${keyName}'`); -} +const WAIT_DEFAULT_DELAY_SEC = 5; +const MAX_WAIT_MINUTES = 360; function isEmptyValue(value) { if (value === null || value === undefined || value === '') { @@ -125,98 +76,20 @@ function removeIgnoredAttributes(taskDef) { return taskDef; } -// Deploy to a service that uses the 'CODE_DEPLOY' deployment controller -async function createCodeDeployDeployment(codedeploy, clusterName, service, taskDefArn, waitForService, waitForMinutes) { - core.debug('Updating AppSpec file with new task definition ARN'); - - let codeDeployAppSpecFile = core.getInput('codedeploy-appspec', { required : false }); - codeDeployAppSpecFile = codeDeployAppSpecFile ? codeDeployAppSpecFile : 'appspec.yaml'; - - let codeDeployApp = core.getInput('codedeploy-application', { required: false }); - codeDeployApp = codeDeployApp ? codeDeployApp : `AppECS-${clusterName}-${service}`; - - let codeDeployGroup = core.getInput('codedeploy-deployment-group', { required: false }); - codeDeployGroup = codeDeployGroup ? codeDeployGroup : `DgpECS-${clusterName}-${service}`; - - let deploymentGroupDetails = await codedeploy.getDeploymentGroup({ - applicationName: codeDeployApp, - deploymentGroupName: codeDeployGroup - }).promise(); - deploymentGroupDetails = deploymentGroupDetails.deploymentGroupInfo; - - // Insert the task def ARN into the appspec file - const appSpecPath = path.isAbsolute(codeDeployAppSpecFile) ? - codeDeployAppSpecFile : - path.join(process.env.GITHUB_WORKSPACE, codeDeployAppSpecFile); - const fileContents = fs.readFileSync(appSpecPath, 'utf8'); - const appSpecContents = yaml.parse(fileContents); - - for (var resource of findAppSpecValue(appSpecContents, 'resources')) { - for (var name in resource) { - const resourceContents = resource[name]; - const properties = findAppSpecValue(resourceContents, 'properties'); - const taskDefKey = findAppSpecKey(properties, 'taskDefinition'); - properties[taskDefKey] = taskDefArn; - } - } - - const appSpecString = JSON.stringify(appSpecContents); - const appSpecHash = crypto.createHash('sha256').update(appSpecString).digest('hex'); - - // Start the deployment with the updated appspec contents - core.debug('Starting CodeDeploy deployment'); - const createDeployResponse = await codedeploy.createDeployment({ - applicationName: codeDeployApp, - deploymentGroupName: codeDeployGroup, - revision: { - revisionType: 'AppSpecContent', - appSpecContent: { - content: appSpecString, - sha256: appSpecHash - } - } - }).promise(); - core.setOutput('codedeploy-deployment-id', createDeployResponse.deploymentId); - core.info(`Deployment started. Watch this deployment's progress in the AWS CodeDeploy console: https://console.aws.amazon.com/codesuite/codedeploy/deployments/${createDeployResponse.deploymentId}?region=${aws.config.region}`); - - // Wait for deployment to complete - if (waitForService && waitForService.toLowerCase() === 'true') { - // Determine wait time - const deployReadyWaitMin = deploymentGroupDetails.blueGreenDeploymentConfiguration.deploymentReadyOption.waitTimeInMinutes; - const terminationWaitMin = deploymentGroupDetails.blueGreenDeploymentConfiguration.terminateBlueInstancesOnDeploymentSuccess.terminationWaitTimeInMinutes; - let totalWaitMin = deployReadyWaitMin + terminationWaitMin + waitForMinutes; - if (totalWaitMin > MAX_WAIT_MINUTES) { - totalWaitMin = MAX_WAIT_MINUTES; - } - const maxAttempts = (totalWaitMin * 60) / WAIT_DEFAULT_DELAY_SEC; - - core.debug(`Waiting for the deployment to complete. Will wait for ${totalWaitMin} minutes`); - await codedeploy.waitFor('deploymentSuccessful', { - deploymentId: createDeployResponse.deploymentId, - $waiter: { - delay: WAIT_DEFAULT_DELAY_SEC, - maxAttempts: maxAttempts - } - }).promise(); - } else { - core.debug('Not waiting for the deployment to complete'); - } -} - async function run() { try { + const agent = 'amazon-ecs-run-task-for-github-actions' + const ecs = new aws.ECS({ - customUserAgent: 'amazon-ecs-deploy-task-definition-for-github-actions' - }); - const codedeploy = new aws.CodeDeploy({ - customUserAgent: 'amazon-ecs-deploy-task-definition-for-github-actions' + customUserAgent: agent }); // Get inputs const taskDefinitionFile = core.getInput('task-definition', { required: true }); - const service = core.getInput('service', { required: false }); const cluster = core.getInput('cluster', { required: false }); - const waitForService = core.getInput('wait-for-service-stability', { required: false }); + const count = core.getInput('count', { required: true }); + const startedBy = core.getInput('started-by', { required: false }) || agent; + const waitForFinish = core.getInput('wait-for-finish', { required: false }) || false; let waitForMinutes = parseInt(core.getInput('wait-for-minutes', { required: false })) || 30; if (waitForMinutes > MAX_WAIT_MINUTES) { waitForMinutes = MAX_WAIT_MINUTES; @@ -229,6 +102,7 @@ async function run() { path.join(process.env.GITHUB_WORKSPACE, taskDefinitionFile); const fileContents = fs.readFileSync(taskDefPath, 'utf8'); const taskDefContents = removeIgnoredAttributes(cleanNullKeys(yaml.parse(fileContents))); + let registerResponse; try { registerResponse = await ecs.registerTaskDefinition(taskDefContents).promise(); @@ -241,37 +115,36 @@ async function run() { const taskDefArn = registerResponse.taskDefinition.taskDefinitionArn; core.setOutput('task-definition-arn', taskDefArn); - // Update the service with the new task definition - if (service) { - const clusterName = cluster ? cluster : 'default'; + const clusterName = cluster ? cluster : 'default'; - // Determine the deployment controller - const describeResponse = await ecs.describeServices({ - services: [service], - cluster: clusterName - }).promise(); + core.debug(`Running task with ${JSON.stringify({ + cluster: clusterName, + taskDefinition: taskDefArn, + count: count, + startedBy: startedBy + })}`) - if (describeResponse.failures && describeResponse.failures.length > 0) { - const failure = describeResponse.failures[0]; - throw new Error(`${failure.arn} is ${failure.reason}`); - } + const runTaskResponse = await ecs.runTask({ + cluster: clusterName, + taskDefinition: taskDefArn, + count: count, + startedBy: startedBy + }).promise(); - const serviceResponse = describeResponse.services[0]; - if (serviceResponse.status != 'ACTIVE') { - throw new Error(`Service is ${serviceResponse.status}`); - } + core.debug(`Run task response ${JSON.stringify(runTaskResponse)}`) - if (!serviceResponse.deploymentController) { - // Service uses the 'ECS' deployment controller, so we can call UpdateService - await updateEcsService(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes); - } else if (serviceResponse.deploymentController.type == 'CODE_DEPLOY') { - // Service uses CodeDeploy, so we should start a CodeDeploy deployment - await createCodeDeployDeployment(codedeploy, clusterName, service, taskDefArn, waitForService, waitForMinutes); - } else { - throw new Error(`Unsupported deployment controller: ${serviceResponse.deploymentController.type}`); - } - } else { - core.debug('Service was not specified, no service updated'); + if (runTaskResponse.failures && runTaskResponse.failures.length > 0) { + const failure = runTaskResponse.failures[0]; + throw new Error(`${failure.arn} is ${failure.reason}`); + } + + const taskArns = runTaskResponse.tasks.map(task => task.taskArn); + + core.setOutput('task-arn', taskArns); + + if (waitForFinish && waitForFinish.toLowerCase() === 'true') { + await waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes); + await tasksExitCode(ecs, clusterName, taskArns); } } catch (error) { @@ -280,6 +153,56 @@ async function run() { } } +async function waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes) { + if (waitForMinutes > MAX_WAIT_MINUTES) { + waitForMinutes = MAX_WAIT_MINUTES; + } + + const maxAttempts = (waitForMinutes * 60) / WAIT_DEFAULT_DELAY_SEC; + + core.debug('Waiting for tasks to stop'); + + const waitTaskResponse = await ecs.waitFor('tasksStopped', { + cluster: clusterName, + tasks: taskArns, + $waiter: { + delay: WAIT_DEFAULT_DELAY_SEC, + maxAttempts: maxAttempts + } + }).promise(); + + core.debug(`Run task response ${JSON.stringify(waitTaskResponse)}`) + + core.info(`All tasks have stopped. Watch progress in the Amazon ECS console: https://console.aws.amazon.com/ecs/home?region=${aws.config.region}#/clusters/${clusterName}/tasks`); +} + +async function tasksExitCode(ecs, clusterName, taskArns) { + const describeResponse = await ecs.describeTasks({ + cluster: clusterName, + tasks: taskArns + }).promise(); + + const containers = [].concat(...describeResponse.tasks.map(task => task.containers)) + const exitCodes = containers.map(container => container.exitCode) + const reasons = containers.map(container => container.reason) + + const failuresIdx = []; + + exitCodes.filter((exitCode, index) => { + if (exitCode !== 0) { + failuresIdx.push(index) + } + }) + + const failures = reasons.filter((_, index) => failuresIdx.indexOf(index) !== -1) + + if (failures.length > 0) { + core.setFailed(failures.join("\n")); + } else { + core.info(`All tasks have exited successfully.`); + } +} + module.exports = run; /* istanbul ignore next */ diff --git a/index.test.js b/index.test.js index f266905f8..9eaec9ae2 100644 --- a/index.test.js +++ b/index.test.js @@ -7,12 +7,9 @@ jest.mock('@actions/core'); jest.mock('fs'); const mockEcsRegisterTaskDef = jest.fn(); -const mockEcsUpdateService = jest.fn(); -const mockEcsDescribeServices = jest.fn(); +const mockEcsDescribeTasks = jest.fn(); +const mockRunTasks = jest.fn(); const mockEcsWaiter = jest.fn(); -const mockCodeDeployCreateDeployment = jest.fn(); -const mockCodeDeployGetDeploymentGroup = jest.fn(); -const mockCodeDeployWaiter = jest.fn(); jest.mock('aws-sdk', () => { return { config: { @@ -20,22 +17,13 @@ jest.mock('aws-sdk', () => { }, ECS: jest.fn(() => ({ registerTaskDefinition: mockEcsRegisterTaskDef, - updateService: mockEcsUpdateService, - describeServices: mockEcsDescribeServices, + describeTasks: mockEcsDescribeTasks, + runTask: mockRunTasks, waitFor: mockEcsWaiter - })), - CodeDeploy: jest.fn(() => ({ - createDeployment: mockCodeDeployCreateDeployment, - getDeploymentGroup: mockCodeDeployGetDeploymentGroup, - waitFor: mockCodeDeployWaiter })) }; }); -const EXPECTED_DEFAULT_WAIT_TIME = 30; -const EXPECTED_CODE_DEPLOY_DEPLOYMENT_READY_WAIT_TIME = 60; -const EXPECTED_CODE_DEPLOY_TERMINATION_WAIT_TIME = 30; - describe('Deploy to ECS', () => { beforeEach(() => { @@ -43,9 +31,10 @@ describe('Deploy to ECS', () => { core.getInput = jest .fn() - .mockReturnValueOnce('task-definition.json') // task-definition - .mockReturnValueOnce('service-456') // service - .mockReturnValueOnce('cluster-789'); // cluster + .mockReturnValueOnce('task-definition.json') // task-definition + .mockReturnValueOnce('cluster-789') // cluster + .mockReturnValueOnce('1') // count + .mockReturnValueOnce('amazon-ecs-run-task-for-github-actions'); // started-by process.env = Object.assign(process.env, { GITHUB_WORKSPACE: __dirname }); @@ -54,18 +43,6 @@ describe('Deploy to ECS', () => { throw new Error(`Wrong encoding ${encoding}`); } - if (pathInput == path.join(process.env.GITHUB_WORKSPACE, 'appspec.yaml')) { - return ` - Resources: - - TargetService: - Type: AWS::ECS::Service - Properties: - TaskDefinition: helloworld - LoadBalancerInfo: - ContainerName: web - ContainerPort: 80`; - } - if (pathInput == path.join(process.env.GITHUB_WORKSPACE, 'task-definition.json')) { return JSON.stringify({ family: 'task-def-family' }); } @@ -73,6 +50,9 @@ describe('Deploy to ECS', () => { throw new Error(`Unknown path ${pathInput}`); }); + //runTask + //describeTask + mockEcsRegisterTaskDef.mockImplementation(() => { return { promise() { @@ -81,63 +61,58 @@ describe('Deploy to ECS', () => { }; }); - mockEcsUpdateService.mockImplementation(() => { - return { - promise() { - return Promise.resolve({}); - } - }; - }); - - mockEcsDescribeServices.mockImplementation(() => { + mockEcsDescribeTasks.mockImplementation(() => { return { promise() { return Promise.resolve({ failures: [], - services: [{ - status: 'ACTIVE' - }] + tasks: [ + { + containers: [ + { + lastStatus: "RUNNING", + exitCode: 0, + reason: '', + taskArn: "arn:aws:ecs:fake-region:account_id:task/arn" + } + ], + desiredStatus: "RUNNING", + lastStatus: "RUNNING", + taskArn: "arn:aws:ecs:fake-region:account_id:task/arn" + } + ] }); } }; }); - mockEcsWaiter.mockImplementation(() => { - return { - promise() { - return Promise.resolve({}); - } - }; - }); - - mockCodeDeployCreateDeployment.mockImplementation(() => { - return { - promise() { - return Promise.resolve({ deploymentId: 'deployment-1' }); - } - }; - }); - - mockCodeDeployGetDeploymentGroup.mockImplementation(() => { + mockRunTasks.mockImplementation(() => { return { promise() { return Promise.resolve({ - deploymentGroupInfo: { - blueGreenDeploymentConfiguration: { - deploymentReadyOption: { - waitTimeInMinutes: EXPECTED_CODE_DEPLOY_DEPLOYMENT_READY_WAIT_TIME - }, - terminateBlueInstancesOnDeploymentSuccess: { - terminationWaitTimeInMinutes: EXPECTED_CODE_DEPLOY_TERMINATION_WAIT_TIME - } + failures: [], + tasks: [ + { + containers: [ + { + lastStatus: "RUNNING", + exitCode: 0, + reason: '', + taskArn: "arn:aws:ecs:fake-region:account_id:task/arn" + } + ], + desiredStatus: "RUNNING", + lastStatus: "RUNNING", + taskArn: "arn:aws:ecs:fake-region:account_id:task/arn" + // taskDefinitionArn: "arn:aws:ecs:::task-definition/amazon-ecs-sample:1" } - } + ] }); } }; }); - mockCodeDeployWaiter.mockImplementation(() => { + mockEcsWaiter.mockImplementation(() => { return { promise() { return Promise.resolve({}); @@ -146,22 +121,43 @@ describe('Deploy to ECS', () => { }); }); - test('registers the task definition contents and updates the service', async () => { + test('registers the task definition contents and runs the task', async () => { await run(); expect(core.setFailed).toHaveBeenCalledTimes(0); expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn'); - expect(mockEcsDescribeServices).toHaveBeenNthCalledWith(1, { + expect(mockRunTasks).toHaveBeenNthCalledWith(1, { cluster: 'cluster-789', - services: ['service-456'] + taskDefinition: 'task:def:arn', + count: '1', + startedBy: 'amazon-ecs-run-task-for-github-actions' }); - expect(mockEcsUpdateService).toHaveBeenNthCalledWith(1, { + expect(mockEcsWaiter).toHaveBeenCalledTimes(0); + expect(core.setOutput).toBeCalledWith('task-arn', ['arn:aws:ecs:fake-region:account_id:task/arn']); + }); + + test('registers the task definition contents and waits for tasks to finish successfully', async () => { + core.getInput = jest + .fn() + .mockReturnValueOnce('task-definition.json') // task-definition + .mockReturnValueOnce('cluster-789') // cluster + .mockReturnValueOnce('1') // count + .mockReturnValueOnce('amazon-ecs-run-task-for-github-actions') // started-by + .mockReturnValueOnce('true'); // wait-for-finish + + await run(); + expect(core.setFailed).toHaveBeenCalledTimes(0); + + expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); + expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn'); + expect(mockEcsDescribeTasks).toHaveBeenNthCalledWith(1, { cluster: 'cluster-789', - service: 'service-456', - taskDefinition: 'task:def:arn' + tasks: ['arn:aws:ecs:fake-region:account_id:task/arn'] }); - expect(mockEcsWaiter).toHaveBeenCalledTimes(0); - expect(core.info).toBeCalledWith("Deployment started. Watch this deployment's progress in the Amazon ECS console: https://console.aws.amazon.com/ecs/home?region=fake-region#/clusters/cluster-789/services/service-456/events"); + + expect(mockEcsWaiter).toHaveBeenCalledTimes(1); + + expect(core.info).toBeCalledWith("All tasks have exited successfully."); }); test('cleans null keys out of the task definition contents', async () => { @@ -264,590 +260,6 @@ describe('Deploy to ECS', () => { expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); }); - test('registers the task definition contents and creates a CodeDeploy deployment, waits for 30 minutes + deployment group wait time', async () => { - core.getInput = jest - .fn() - .mockReturnValueOnce('task-definition.json') // task-definition - .mockReturnValueOnce('service-456') // service - .mockReturnValueOnce('cluster-789') // cluster - .mockReturnValueOnce('TRUE'); // wait-for-service-stability - - mockEcsDescribeServices.mockImplementation(() => { - return { - promise() { - return Promise.resolve({ - failures: [], - services: [{ - status: 'ACTIVE', - deploymentController: { - type: 'CODE_DEPLOY' - } - }] - }); - } - }; - }); - - await run(); - expect(core.setFailed).toHaveBeenCalledTimes(0); - - expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); - expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn'); - expect(mockEcsDescribeServices).toHaveBeenNthCalledWith(1, { - cluster: 'cluster-789', - services: ['service-456'] - }); - - expect(mockCodeDeployCreateDeployment).toHaveBeenNthCalledWith(1, { - applicationName: 'AppECS-cluster-789-service-456', - deploymentGroupName: 'DgpECS-cluster-789-service-456', - revision: { - revisionType: 'AppSpecContent', - appSpecContent: { - content: JSON.stringify({ - Resources: [{ - TargetService: { - Type: 'AWS::ECS::Service', - Properties: { - TaskDefinition: 'task:def:arn', - LoadBalancerInfo: { - ContainerName: "web", - ContainerPort: 80 - } - } - } - }] - }), - sha256: '0911d1e99f48b492e238d1284d8ddb805382d33e1d1fc74ffadf37d8b7e6d096' - } - } - }); - - expect(mockCodeDeployWaiter).toHaveBeenNthCalledWith(1, 'deploymentSuccessful', { - deploymentId: 'deployment-1', - $waiter: { - delay: 15, - maxAttempts: ( - EXPECTED_DEFAULT_WAIT_TIME + - EXPECTED_CODE_DEPLOY_TERMINATION_WAIT_TIME + - EXPECTED_CODE_DEPLOY_DEPLOYMENT_READY_WAIT_TIME - ) * 4 - } - }); - - expect(mockEcsUpdateService).toHaveBeenCalledTimes(0); - expect(mockEcsWaiter).toHaveBeenCalledTimes(0); - - expect(core.info).toBeCalledWith("Deployment started. Watch this deployment's progress in the AWS CodeDeploy console: https://console.aws.amazon.com/codesuite/codedeploy/deployments/deployment-1?region=fake-region"); - }); - - test('registers the task definition contents and creates a CodeDeploy deployment, waits for 1 hour + deployment group\'s wait time', async () => { - core.getInput = jest - .fn() - .mockReturnValueOnce('task-definition.json') // task-definition - .mockReturnValueOnce('service-456') // service - .mockReturnValueOnce('cluster-789') // cluster - .mockReturnValueOnce('TRUE') // wait-for-service-stability - .mockReturnValueOnce('60'); // wait-for-minutes - - mockEcsDescribeServices.mockImplementation(() => { - return { - promise() { - return Promise.resolve({ - failures: [], - services: [{ - status: 'ACTIVE', - deploymentController: { - type: 'CODE_DEPLOY' - } - }] - }); - } - }; - }); - - await run(); - expect(core.setFailed).toHaveBeenCalledTimes(0); - - expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); - expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn'); - expect(mockEcsDescribeServices).toHaveBeenNthCalledWith(1, { - cluster: 'cluster-789', - services: ['service-456'] - }); - - expect(mockCodeDeployCreateDeployment).toHaveBeenNthCalledWith(1, { - applicationName: 'AppECS-cluster-789-service-456', - deploymentGroupName: 'DgpECS-cluster-789-service-456', - revision: { - revisionType: 'AppSpecContent', - appSpecContent: { - content: JSON.stringify({ - Resources: [{ - TargetService: { - Type: 'AWS::ECS::Service', - Properties: { - TaskDefinition: 'task:def:arn', - LoadBalancerInfo: { - ContainerName: "web", - ContainerPort: 80 - } - } - } - }] - }), - sha256: '0911d1e99f48b492e238d1284d8ddb805382d33e1d1fc74ffadf37d8b7e6d096' - } - } - }); - - expect(mockCodeDeployWaiter).toHaveBeenNthCalledWith(1, 'deploymentSuccessful', { - deploymentId: 'deployment-1', - $waiter: { - delay: 15, - maxAttempts: ( - 60 + - EXPECTED_CODE_DEPLOY_TERMINATION_WAIT_TIME + - EXPECTED_CODE_DEPLOY_DEPLOYMENT_READY_WAIT_TIME - ) * 4 - } - }); - - expect(mockEcsUpdateService).toHaveBeenCalledTimes(0); - expect(mockEcsWaiter).toHaveBeenCalledTimes(0); - }); - - test('registers the task definition contents and creates a CodeDeploy deployment, waits for max 6 hours', async () => { - core.getInput = jest - .fn() - .mockReturnValueOnce('task-definition.json') // task-definition - .mockReturnValueOnce('service-456') // service - .mockReturnValueOnce('cluster-789') // cluster - .mockReturnValueOnce('TRUE') // wait-for-service-stability - .mockReturnValueOnce('1000'); // wait-for-minutes - - mockEcsDescribeServices.mockImplementation(() => { - return { - promise() { - return Promise.resolve({ - failures: [], - services: [{ - status: 'ACTIVE', - deploymentController: { - type: 'CODE_DEPLOY' - } - }] - }); - } - }; - }); - - await run(); - expect(core.setFailed).toHaveBeenCalledTimes(0); - - expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); - expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn'); - expect(mockEcsDescribeServices).toHaveBeenNthCalledWith(1, { - cluster: 'cluster-789', - services: ['service-456'] - }); - - expect(mockCodeDeployCreateDeployment).toHaveBeenNthCalledWith(1, { - applicationName: 'AppECS-cluster-789-service-456', - deploymentGroupName: 'DgpECS-cluster-789-service-456', - revision: { - revisionType: 'AppSpecContent', - appSpecContent: { - content: JSON.stringify({ - Resources: [{ - TargetService: { - Type: 'AWS::ECS::Service', - Properties: { - TaskDefinition: 'task:def:arn', - LoadBalancerInfo: { - ContainerName: "web", - ContainerPort: 80 - } - } - } - }] - }), - sha256: '0911d1e99f48b492e238d1284d8ddb805382d33e1d1fc74ffadf37d8b7e6d096' - } - } - }); - - expect(mockCodeDeployWaiter).toHaveBeenNthCalledWith(1, 'deploymentSuccessful', { - deploymentId: 'deployment-1', - $waiter: { - delay: 15, - maxAttempts: 6 * 60 * 4 - } - }); - - expect(mockEcsUpdateService).toHaveBeenCalledTimes(0); - expect(mockEcsWaiter).toHaveBeenCalledTimes(0); - }); - - test('does not wait for a CodeDeploy deployment, parses JSON appspec file', async () => { - core.getInput = jest - .fn() - .mockReturnValueOnce('task-definition.json') // task-definition - .mockReturnValueOnce('service-456') // service - .mockReturnValueOnce('cluster-789') // cluster - .mockReturnValueOnce('false') // wait-for-service-stability - .mockReturnValueOnce('') // wait-for-minutes - .mockReturnValueOnce('/hello/appspec.json') // codedeploy-appspec - .mockReturnValueOnce('MyApplication') // codedeploy-application - .mockReturnValueOnce('MyDeploymentGroup'); // codedeploy-deployment-group - - fs.readFileSync.mockReturnValue(` - { - "Resources": [ - { - "TargetService": { - "Type": "AWS::ECS::Service", - "Properties": { - "TaskDefinition": "helloworld", - "LoadBalancerInfo": { - "ContainerName": "web", - "ContainerPort": 80 - } - } - } - } - ] - } - `); - - fs.readFileSync.mockImplementation((pathInput, encoding) => { - if (encoding != 'utf8') { - throw new Error(`Wrong encoding ${encoding}`); - } - - if (pathInput == path.join('/hello/appspec.json')) { - return ` - { - "Resources": [ - { - "TargetService": { - "Type": "AWS::ECS::Service", - "Properties": { - "TaskDefinition": "helloworld", - "LoadBalancerInfo": { - "ContainerName": "web", - "ContainerPort": 80 - } - } - } - } - ] - }`; - } - - if (pathInput == path.join(process.env.GITHUB_WORKSPACE, 'task-definition.json')) { - return JSON.stringify({ family: 'task-def-family' }); - } - - throw new Error(`Unknown path ${pathInput}`); - }); - - mockEcsDescribeServices.mockImplementation(() => { - return { - promise() { - return Promise.resolve({ - failures: [], - services: [{ - status: 'ACTIVE', - deploymentController: { - type: 'CODE_DEPLOY' - } - }] - }); - } - }; - }); - - await run(); - expect(core.setFailed).toHaveBeenCalledTimes(0); - - expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); - expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn'); - expect(mockEcsDescribeServices).toHaveBeenNthCalledWith(1, { - cluster: 'cluster-789', - services: ['service-456'] - }); - - expect(mockCodeDeployCreateDeployment).toHaveBeenNthCalledWith(1, { - applicationName: 'MyApplication', - deploymentGroupName: 'MyDeploymentGroup', - revision: { - revisionType: 'AppSpecContent', - appSpecContent: { - content: JSON.stringify({ - Resources: [{ - TargetService: { - Type: 'AWS::ECS::Service', - Properties: { - TaskDefinition: 'task:def:arn', - LoadBalancerInfo: { - ContainerName: "web", - ContainerPort: 80 - } - } - } - }] - }), - sha256: '0911d1e99f48b492e238d1284d8ddb805382d33e1d1fc74ffadf37d8b7e6d096' - } - } - }); - - expect(mockCodeDeployWaiter).toHaveBeenCalledTimes(0); - expect(mockEcsUpdateService).toHaveBeenCalledTimes(0); - expect(mockEcsWaiter).toHaveBeenCalledTimes(0); - }); - - test('registers the task definition contents at an absolute path', async () => { - core.getInput = jest.fn().mockReturnValueOnce('/hello/task-definition.json'); - fs.readFileSync.mockImplementation((pathInput, encoding) => { - if (encoding != 'utf8') { - throw new Error(`Wrong encoding ${encoding}`); - } - - if (pathInput == '/hello/task-definition.json') { - return JSON.stringify({ family: 'task-def-family-absolute-path' }); - } - - throw new Error(`Unknown path ${pathInput}`); - }); - - await run(); - expect(core.setFailed).toHaveBeenCalledTimes(0); - - expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family-absolute-path'}); - expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn'); - }); - - test('waits for the service to be stable', async () => { - core.getInput = jest - .fn() - .mockReturnValueOnce('task-definition.json') // task-definition - .mockReturnValueOnce('service-456') // service - .mockReturnValueOnce('cluster-789') // cluster - .mockReturnValueOnce('TRUE'); // wait-for-service-stability - - await run(); - expect(core.setFailed).toHaveBeenCalledTimes(0); - - expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); - expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn'); - expect(mockEcsDescribeServices).toHaveBeenNthCalledWith(1, { - cluster: 'cluster-789', - services: ['service-456'] - }); - expect(mockEcsUpdateService).toHaveBeenNthCalledWith(1, { - cluster: 'cluster-789', - service: 'service-456', - taskDefinition: 'task:def:arn' - }); - expect(mockEcsWaiter).toHaveBeenNthCalledWith(1, 'servicesStable', { - services: ['service-456'], - cluster: 'cluster-789', - "$waiter": { - "delay": 15, - "maxAttempts": EXPECTED_DEFAULT_WAIT_TIME * 4, - }, - }); - }); - - test('waits for the service to be stable for specified minutes', async () => { - core.getInput = jest - .fn() - .mockReturnValueOnce('task-definition.json') // task-definition - .mockReturnValueOnce('service-456') // service - .mockReturnValueOnce('cluster-789') // cluster - .mockReturnValueOnce('TRUE') // wait-for-service-stability - .mockReturnValue('60'); // wait-for-minutes - - await run(); - expect(core.setFailed).toHaveBeenCalledTimes(0); - - expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); - expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn'); - expect(mockEcsDescribeServices).toHaveBeenNthCalledWith(1, { - cluster: 'cluster-789', - services: ['service-456'] - }); - expect(mockEcsUpdateService).toHaveBeenNthCalledWith(1, { - cluster: 'cluster-789', - service: 'service-456', - taskDefinition: 'task:def:arn' - }); - expect(mockEcsWaiter).toHaveBeenNthCalledWith(1, 'servicesStable', { - services: ['service-456'], - cluster: 'cluster-789', - "$waiter": { - "delay": 15, - "maxAttempts": 60 * 4, - }, - }); - }); - - test('waits for the service to be stable for max 6 hours', async () => { - core.getInput = jest - .fn() - .mockReturnValueOnce('task-definition.json') // task-definition - .mockReturnValueOnce('service-456') // service - .mockReturnValueOnce('cluster-789') // cluster - .mockReturnValueOnce('TRUE') // wait-for-service-stability - .mockReturnValue('1000'); // wait-for-minutes - - await run(); - expect(core.setFailed).toHaveBeenCalledTimes(0); - - expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); - expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn'); - expect(mockEcsDescribeServices).toHaveBeenNthCalledWith(1, { - cluster: 'cluster-789', - services: ['service-456'] - }); - expect(mockEcsUpdateService).toHaveBeenNthCalledWith(1, { - cluster: 'cluster-789', - service: 'service-456', - taskDefinition: 'task:def:arn' - }); - expect(mockEcsWaiter).toHaveBeenNthCalledWith(1, 'servicesStable', { - services: ['service-456'], - cluster: 'cluster-789', - "$waiter": { - "delay": 15, - "maxAttempts": 6 * 60 * 4, - }, - }); - }); - - test('defaults to the default cluster', async () => { - core.getInput = jest - .fn() - .mockReturnValueOnce('task-definition.json') // task-definition - .mockReturnValueOnce('service-456'); // service - - await run(); - expect(core.setFailed).toHaveBeenCalledTimes(0); - - expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); - expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn'); - expect(mockEcsDescribeServices).toHaveBeenNthCalledWith(1, { - cluster: 'default', - services: ['service-456'] - }); - expect(mockEcsUpdateService).toHaveBeenNthCalledWith(1, { - cluster: 'default', - service: 'service-456', - taskDefinition: 'task:def:arn' - }); - }); - - test('does not update service if none specified', async () => { - core.getInput = jest - .fn() - .mockReturnValueOnce('task-definition.json'); // task-definition - - await run(); - expect(core.setFailed).toHaveBeenCalledTimes(0); - - expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); - expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn'); - expect(mockEcsDescribeServices).toHaveBeenCalledTimes(0); - expect(mockEcsUpdateService).toHaveBeenCalledTimes(0); - }); - - test('error caught if AppSpec file is not formatted correctly', async () => { - mockEcsDescribeServices.mockImplementation(() => { - return { - promise() { - return Promise.resolve({ - failures: [], - services: [{ - status: 'ACTIVE', - deploymentController: { - type: 'CODE_DEPLOY' - } - }] - }); - } - }; - }); - fs.readFileSync.mockReturnValue("hello: world"); - - await run(); - - expect(core.setFailed).toBeCalledWith("AppSpec file must include property 'resources'"); - }); - - test('error is caught if service does not exist', async () => { - mockEcsDescribeServices.mockImplementation(() => { - return { - promise() { - return Promise.resolve({ - failures: [{ - reason: 'MISSING', - arn: 'hello' - }], - services: [] - }); - } - }; - }); - - await run(); - - expect(core.setFailed).toBeCalledWith('hello is MISSING'); - }); - - test('error is caught if service is inactive', async () => { - mockEcsDescribeServices.mockImplementation(() => { - return { - promise() { - return Promise.resolve({ - failures: [], - services: [{ - status: 'INACTIVE' - }] - }); - } - }; - }); - - await run(); - - expect(core.setFailed).toBeCalledWith('Service is INACTIVE'); - }); - - test('error is caught if service uses external deployment controller', async () => { - mockEcsDescribeServices.mockImplementation(() => { - return { - promise() { - return Promise.resolve({ - failures: [], - services: [{ - status: 'ACTIVE', - deploymentController: { - type: 'EXTERNAL' - } - }] - }); - } - }; - }); - - await run(); - - expect(core.setFailed).toBeCalledWith('Unsupported deployment controller: EXTERNAL'); - }); - - test('error is caught if task def registration fails', async () => { mockEcsRegisterTaskDef.mockImplementation(() => { throw new Error("Could not parse");