├── CODE_OF_CONDUCT.md ├── tsconfig.json ├── esbuild.config.mjs ├── .eslintrc.json ├── jest.config.json ├── src ├── action-configuration.test.ts ├── test-helpers │ └── fake-input.ts ├── index.ts ├── client-apprunner-commands.ts ├── action-configuration.ts ├── action-helper-functions.ts └── index.test.ts ├── LICENSE ├── .gitignore ├── package.json ├── CONTRIBUTING.md ├── action.yml ├── CHANGELOG.md ├── README.md └── THIRD-PARTY /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node16/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist" /* Redirect output structure to the directory. */, 5 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 6 | "resolveJsonModule": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import { build } from "esbuild"; 2 | 3 | build({ 4 | entryPoints: [ "./src/index.js" ], 5 | bundle: true, 6 | platform: 'node', 7 | target: 'node16', 8 | outdir: './dist', 9 | minify: true, 10 | mainFields: ['module', 'main'], 11 | }).catch(err => { 12 | console.log(err.message); 13 | process.exit(1); 14 | }); 15 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint/eslint-plugin" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "rules": { 13 | "@typescript-eslint/explicit-function-return-type": "error" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "testEnvironment": "node", 3 | "verbose": true, 4 | "clearMocks": true, 5 | "coverageReporters": ["json", "lcov", "text"], 6 | "coveragePathIgnorePatterns": [ 7 | "/node_moduoles/", 8 | "/dist/" 9 | ], 10 | "roots": [ 11 | "/src" 12 | ], 13 | "testMatch": [ 14 | "**/?(*.)+(test|spec)\\.ts" 15 | ], 16 | "transform": { 17 | "^.+\\.(ts|tsx)$": "ts-jest" 18 | } 19 | } -------------------------------------------------------------------------------- /src/action-configuration.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from "@jest/globals"; 2 | import { getConfig } from "./action-configuration"; 3 | 4 | describe("getConfig", () => { 5 | const originalEnv = { ...process.env }; 6 | 7 | beforeEach(() => { 8 | process.env = { 9 | ...originalEnv, 10 | "INPUT_SERVICE": "service_name", 11 | "INPUT_SOURCE-CONNECTION-ARN": "source_connection_arn", 12 | "INPUT_REPO": "repo_url", 13 | "INPUT_RUNTIME": "NODEJS_16", 14 | "INPUT_BUILD-COMMAND": "build-command", 15 | "INPUT_START-COMMAND": "start-command" 16 | } 17 | }) 18 | 19 | afterEach(() => { 20 | process.env = { ...originalEnv } 21 | }) 22 | 23 | test("autoscaling config ARN is undefined when input is not specified/empty", () => { 24 | expect(getConfig().autoScalingConfigArn).toBeUndefined(); 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editors 2 | .vscode 3 | .idea 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | yarn.lock 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | run.sh 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Other Dependency directories 42 | jspm_packages/ 43 | node_modules 44 | target 45 | 46 | # TypeScript v1 declaration files 47 | typings/ 48 | 49 | # Optional npm cache directory 50 | .npm 51 | 52 | # Optional eslint cache 53 | .eslintcache 54 | 55 | # Optional REPL history 56 | .node_repl_history 57 | 58 | # Output of 'npm pack' 59 | *.tgz 60 | 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # dotenv environment variables file 65 | .env 66 | 67 | # next.js build output 68 | .next 69 | 70 | # 71 | cloudformation.schema.json 72 | sam.schema.json 73 | 74 | .DS_Store -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-actions-app-runner-deploy-service", 3 | "version": "2.5.2", 4 | "description": "Registers and deploys the application as AppRunner service.", 5 | "main": "./dist/index.js", 6 | "scripts": { 7 | "lint": "eslint ./src/*.ts", 8 | "package": "node ./esbuild.config.mjs", 9 | "test": "npm run lint && jest --coverage" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/awslabs/amazon-app-runner-deploy.git" 14 | }, 15 | "keywords": [ 16 | "AWS", 17 | "GitHub", 18 | "Actions", 19 | "JavaScript" 20 | ], 21 | "author": "AWS", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/awslabs/amazon-app-runner-deploy/issues" 25 | }, 26 | "homepage": "https://github.com/awslabs/amazon-app-runner-deploy#readme", 27 | "dependencies": { 28 | "@actions/core": "^1.10.1", 29 | "@aws-sdk/client-apprunner": "^3.575.0", 30 | "@aws-sdk/node-http-handler": "^3.370.0", 31 | "yaml": "^2.3.3" 32 | }, 33 | "devDependencies": { 34 | "@tsconfig/node16": "^16.1.1", 35 | "@types/jest": "^29.5.6", 36 | "@typescript-eslint/eslint-plugin": "^6.9.0", 37 | "@typescript-eslint/parser": "^6.9.0", 38 | "esbuild": "^0.19.5", 39 | "eslint": "^8.52.0", 40 | "jest": "^29.7.0", 41 | "ts-jest": "^29.1.1", 42 | "ts-node": "^10.9.1", 43 | "type-fest": "^4.6.0", 44 | "typescript": "^5.2.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/test-helpers/fake-input.ts: -------------------------------------------------------------------------------- 1 | import { InputOptions } from "@actions/core"; 2 | 3 | export interface FakeInput { 4 | action?: string; 5 | service?: string; 6 | 'source-connection-arn'?: string; 7 | 'access-role-arn'?: string; 8 | 'instance-role-arn'?: string; 9 | repo?: string; 10 | image?: string; 11 | runtime?: string; 12 | 'build-command'?: string; 13 | 'start-command'?: string; 14 | port?: string; 15 | 'wait-for-service-stability'?: string; 16 | 'wait-for-service-stability-seconds'?: string; 17 | region?: string; 18 | branch?: string; 19 | cpu?: string; 20 | memory?: string; 21 | tags?: string; 22 | 'auto-scaling-config-arn'?: string; 23 | } 24 | 25 | export interface FakeMultilineInput { 26 | 'copy-env-vars'?: string[]; 27 | 'copy-secret-env-vars'?: string[]; 28 | } 29 | 30 | export function getFakeMultilineInput(config: FakeMultilineInput, name: string, options?: InputOptions): string[] { 31 | if (Object.keys(config).includes(name)) { 32 | return (config as { [key: string]: string[] })[name]; 33 | } else { 34 | if (options?.required) { 35 | throw new Error(`${name} is required`); 36 | } else { 37 | return []; 38 | } 39 | } 40 | } 41 | 42 | export function getFakeInput(config: FakeInput, name: string, options?: InputOptions): string { 43 | if (Object.keys(config).includes(name)) { 44 | return (config as { [key: string]: string })[name]; 45 | } else { 46 | if (options?.required) { 47 | throw new Error(`${name} is required`); 48 | } else { 49 | return ''; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { info, setFailed, setOutput } from "@actions/core"; 2 | import { AppRunnerClient } from "@aws-sdk/client-apprunner"; 3 | import { debug } from '@actions/core'; 4 | import { getConfig } from "./action-configuration"; 5 | import { createOrUpdateService, findExistingService, waitToStabilize, checkOperationIsSucceeded } from "./action-helper-functions"; 6 | import { version as package_version } from "../package.json"; 7 | 8 | // GitHub action handler function 9 | export async function run(): Promise { 10 | 11 | try { 12 | // Parse action configuration information into a strongly typed object 13 | const config = getConfig(); 14 | 15 | // AppRunner client 16 | const client = new AppRunnerClient({ region: config.region }); 17 | 18 | // Check whether service exists 19 | const existingService = await findExistingService(client, config.serviceName); 20 | 21 | // Create or update service, depending on whether it already exists 22 | const serviceInfo = await createOrUpdateService(client, config, existingService); 23 | 24 | // Set outputs 25 | setOutput('service-id', serviceInfo.ServiceId); 26 | setOutput('service-arn', serviceInfo.ServiceArn); 27 | setOutput('service-url', serviceInfo.ServiceUrl); 28 | 29 | // Wait for service to be stable (if required) 30 | if (config.waitForService) { 31 | await waitToStabilize(client, serviceInfo.ServiceArn, config.waitTimeout); 32 | 33 | if (existingService && serviceInfo.OperationId) { 34 | await checkOperationIsSucceeded(client, serviceInfo.ServiceArn, serviceInfo.OperationId); 35 | } 36 | } else { 37 | info( 38 | `Service ${serviceInfo.ServiceId} has started an update. Watch for its progress in the AppRunner console` 39 | ); 40 | } 41 | } catch (error) { 42 | if (error instanceof Error) { 43 | setFailed(error.message); 44 | debug(error.stack ?? 'no stack info'); 45 | } else { 46 | setFailed(JSON.stringify(error)); 47 | } 48 | } 49 | } 50 | 51 | if (require.main === module) { 52 | info(`Version: ${package_version ?? 'undefined'}`); 53 | 54 | run().then(() => { 55 | info('App Runner step - DONE!'); 56 | }).catch(err => { 57 | setFailed(`App Runner unhandled exception: ${err.message}`); 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 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. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | 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' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | 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. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Amazon App Runner "Deploy Service" Action for GitHub Actions' 2 | description: 'Registers and deploys the application as App Runner service' 3 | branding: 4 | icon: 'cloud' 5 | color: 'orange' 6 | inputs: 7 | service: 8 | description: 'The name of the App Runner service' 9 | required: true 10 | source-connection-arn: 11 | description: 'ARN for the `GitHubConnection` in App Runner' 12 | required: false 13 | access-role-arn: 14 | description: 'ARN for IAM Role, if the App Runner service is configured with ECR image' 15 | required: false 16 | instance-role-arn: 17 | description: 'ARN for IAM Role, if the App Runner application accesses AWS resources' 18 | required: false 19 | repo: 20 | description: 'GitHub source repository URL. Cannot be set when "image" parameter is set.' 21 | required: false 22 | branch: 23 | description: "Repository branch name" 24 | required: false 25 | default: 'main' 26 | image: 27 | description: 'Docker image URI with tag. Cannot be set when "repo" parameter is set.' 28 | required: false 29 | runtime: 30 | description: 'Runtime of the application. Refer to https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-apprunner/enums/runtime.html for the list of supported runtime identifiers.' 31 | required: false 32 | build-command: 33 | description: "Application build command" 34 | required: false 35 | start-command: 36 | description: "Application start command" 37 | required: false 38 | port: 39 | description: "The service uses this TCP port (default is 80)" 40 | required: false 41 | default: '80' 42 | region: 43 | description: "AWS deployment region (default is us-east-1)" 44 | required: false 45 | default: 'us-east-1' 46 | cpu: 47 | description: 'Number of vCPUs for the service (default is 1 vCPU)' 48 | required: false 49 | default: '1' 50 | memory: 51 | description: 'Memory for the service (GB) (default is 2GB)' 52 | required: false 53 | default: '2' 54 | wait-for-service-stability: 55 | description: 'Whether to wait for the App Runner service to reach stable state after deploying the new task definition. Valid value is "true". Will default to not waiting.' 56 | required: false 57 | default: 'false' 58 | deprecationMessage: 'This input parameter will be removed in a future release - use "wait-for-service-stability-seconds", which allows you to control the timeout duration' 59 | wait-for-service-stability-seconds: 60 | description: '(Optional) Timeout in seconds to wait for the service deployment process to complete. The default is 600 seconds if "wait-for-service-stability" is set to true and there is no "wait-for-service-stability-seconds" parameter value specified.' 61 | required: false 62 | copy-env-vars: 63 | description: 'Multiline list of pipeline environment variables to be copied over to the App Runner service environment' 64 | required: false 65 | copy-secret-env-vars: 66 | description: 'Multiline list of pipeline environment variables to be copied over to the App Runner service secret environment' 67 | required: false 68 | tags: 69 | description: 'key-value pairs to associate with the service. This input should be JSON-formatted, for example { "env": "test" }' 70 | required: false 71 | auto-scaling-config-arn: 72 | description: 'ARN of an App Runner automatic scaling configuration that you want to associate with the App Runner service' 73 | required: false 74 | outputs: 75 | service-id: 76 | description: 'App Runner service ID' 77 | service-arn: 78 | description: 'App Runner service ARN' 79 | service-url: 80 | description: 'App Runner service URL' 81 | runs: 82 | using: 'node16' 83 | main: 'dist/index.js' 84 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [2.5.2] - 2023-10-30 9 | 10 | ### Changed 11 | 12 | - NPM dependency updates 13 | 14 | ## [2.5.1] - 2023-10-10 15 | 16 | ### Changed 17 | 18 | - NPM dependency updates 19 | 20 | ## [2.5.0] - 2023-06-14 21 | 22 | ### Changed 23 | 24 | - Allow using float value in memory and cpu config ([#42](https://github.com/awslabs/amazon-app-runner-deploy/pull/42)) 25 | - NPM dependency updates 26 | 27 | ## [2.4.0] - 2023-04-27 28 | 29 | ### Added 30 | 31 | - Support RuntimeEnvironmentSecrets ([#29](https://github.com/awslabs/amazon-app-runner-deploy/issues/29)) 32 | - Support InstanceRoleArn ([#40](https://github.com/awslabs/amazon-app-runner-deploy/issues/40)) 33 | 34 | ### Changed 35 | 36 | - NPM dependency updates 37 | 38 | ## [2.3.0] - 2023-04-19 39 | 40 | ### Added 41 | 42 | - Action will fail if the deployment was rolled back due to an error ([#11](https://github.com/awslabs/amazon-app-runner-deploy/issues/11), [#32](https://github.com/awslabs/amazon-app-runner-deploy/issues/32)) 43 | 44 | ## [2.2.2] - 2023-03-30 45 | 46 | ### Added 47 | 48 | - Action version logging 49 | 50 | ### Changed 51 | 52 | - Update all external NPM dependencies 53 | 54 | ## [2.2.1] - 2023-03-29 55 | 56 | ### Fixed 57 | 58 | - Error: The auto scaling configuration ARN you specified isn't valid. ARN: '' ([#36](https://github.com/awslabs/amazon-app-runner-deploy/issues/36)) 59 | 60 | ## [2.2.0] - 2023-03-28 61 | 62 | ### Added 63 | 64 | - Optional `auto-scaling-config-arn` input. When specified this will be applied to new and existing services. 65 | 66 | ## [2.1.0] - 2023-02-08 67 | 68 | ### Added 69 | 70 | - Tagging support ([#21](https://github.com/awslabs/amazon-app-runner-deploy/issues/21)) 71 | 72 | ## [2.0.1] - 2023-01-10 73 | 74 | - Dependency updates 75 | 76 | ## [2.0.0] - 2022-10-02 77 | 78 | ### Added 79 | 80 | - Action outputs: `service-arn` and `service-url`. 81 | - Optional `wait-for-service-stability-seconds` configuration parameter. Valid range is between 10 and 3600 seconds. The default is 600 seconds if `wait-for-service-stability` flag is set to true. This is a replacement for the existing `wait-for-service-stability` boolean flag, which will be deprecated in a future release. 82 | - Optional `action` parameter. The only valid value is `create_or_update` (case insensitive) and the parameter is optional. It is introduced for adding more sub-actions in a future release. 83 | - Support for additional `runtime` parameter values: `DOTNET_6`, `GO_1`, `NODEJS_16`, `PHP_81`, `RUBY_31`. 84 | - If there is an existing service with CREATE_FAILED status, it will be deleted first, before the new service creation is attempted. 85 | - [Add support for environment variables](https://github.com/awslabs/amazon-app-runner-deploy/issues/4). 86 | 87 | ### Changed 88 | 89 | - **BREAKING CHANGE**: The default branch name is now `main`. 90 | - **BREAKING CHANGE**: Action migrated to run on Node16. 91 | - **BREAKING CHANGE**: Update all NPM module references, including major version upgrades. 92 | - **BREAKING CHANGE**: Refactor code to be compatible with future enhancements and updates. 93 | - **BREAKING CHANGE**: Log messages changed to match the new refactored code structure. 94 | - Supported runtime list is no longer hardcoded, but automatically synchronized with the one, defined by [AppRunner Client SDK](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-apprunner/enums/runtime.html), so that new runtime identifier will become available with AWS SDK NPM module updates. 95 | - Mark `wait-for-service-stability` as obsolete. 96 | 97 | ### Fixed 98 | 99 | - [Image is ignored for existing App Runner Service](https://github.com/awslabs/amazon-app-runner-deploy/issues/13) 100 | - [Runtime Support for Node.js 16](https://github.com/awslabs/amazon-app-runner-deploy/issues/10) 101 | 102 | ## [1.x] 103 | 104 | The initial family of releases with core functionality. 105 | -------------------------------------------------------------------------------- /src/client-apprunner-commands.ts: -------------------------------------------------------------------------------- 1 | import { CreateServiceCommand, DeleteServiceCommand, DescribeServiceCommand, ImageRepositoryType, ListServicesCommand, SourceConfiguration, UpdateServiceCommand, TagResourceCommand, ListOperationsCommand } from "@aws-sdk/client-apprunner"; 2 | import { ICodeConfiguration, ICreateOrUpdateActionParams, IImageConfiguration } from "./action-configuration"; 3 | 4 | export function getCreateCommand(config: ICreateOrUpdateActionParams): CreateServiceCommand { 5 | return new CreateServiceCommand({ 6 | ServiceName: config.serviceName, 7 | InstanceConfiguration: { 8 | Cpu: `${config.cpu} vCPU`, 9 | Memory: `${config.memory} GB`, 10 | InstanceRoleArn: config.instanceRoleArn, 11 | }, 12 | AutoScalingConfigurationArn: config.autoScalingConfigArn, 13 | SourceConfiguration: (config.sourceConfig.sourceType == 'image') 14 | ? getImageSourceConfiguration(config.port, config.sourceConfig, config.environment, config.environmentSecret) 15 | : getCodeSourceConfiguration(config.port, config.sourceConfig, config.environment, config.environmentSecret), 16 | Tags: config.tags, 17 | }); 18 | } 19 | 20 | export function getUpdateCommand(serviceArn: string, config: ICreateOrUpdateActionParams): UpdateServiceCommand { 21 | return new UpdateServiceCommand({ 22 | ServiceArn: serviceArn, 23 | InstanceConfiguration: { 24 | Cpu: `${config.cpu} vCPU`, 25 | Memory: `${config.memory} GB`, 26 | InstanceRoleArn: config.instanceRoleArn, 27 | }, 28 | AutoScalingConfigurationArn: config.autoScalingConfigArn, 29 | SourceConfiguration: (config.sourceConfig.sourceType == 'image') 30 | ? getImageSourceConfiguration(config.port, config.sourceConfig, config.environment, config.environmentSecret) 31 | : getCodeSourceConfiguration(config.port, config.sourceConfig, config.environment, config.environmentSecret), 32 | }); 33 | } 34 | 35 | export function getTagResourceCommand(serviceArn: string, config: ICreateOrUpdateActionParams): TagResourceCommand { 36 | return new TagResourceCommand({ 37 | ResourceArn: serviceArn, 38 | Tags: config.tags, 39 | }) 40 | } 41 | 42 | export function getDeleteCommand(serviceArn: string): DeleteServiceCommand { 43 | return new DeleteServiceCommand({ 44 | ServiceArn: serviceArn, 45 | }); 46 | } 47 | 48 | export function getDescribeCommand(serviceArn: string): DescribeServiceCommand { 49 | return new DescribeServiceCommand({ 50 | ServiceArn: serviceArn, 51 | }); 52 | } 53 | 54 | export function getListCommand(nextToken?: string): ListServicesCommand { 55 | return new ListServicesCommand({ 56 | NextToken: nextToken, 57 | }); 58 | } 59 | 60 | export function getListOperationsCommand(serviceArn: string): ListOperationsCommand { 61 | return new ListOperationsCommand({ 62 | ServiceArn: serviceArn, 63 | }); 64 | } 65 | 66 | // Determine ECR image repository type 67 | function getImageType(imageUri: string): ImageRepositoryType { 68 | return imageUri.startsWith("public.ecr") ? ImageRepositoryType.ECR_PUBLIC : ImageRepositoryType.ECR 69 | } 70 | 71 | function getCodeSourceConfiguration(port: number, config: ICodeConfiguration, runtimeEnvironmentVariables?: Record, runtimeEnvironmentSecrets?: Record): SourceConfiguration { 72 | return { 73 | AuthenticationConfiguration: { 74 | ConnectionArn: config.sourceConnectionArn, 75 | }, 76 | AutoDeploymentsEnabled: true, 77 | CodeRepository: { 78 | RepositoryUrl: config.repoUrl, 79 | SourceCodeVersion: { 80 | Type: 'BRANCH', 81 | Value: config.branch, 82 | }, 83 | CodeConfiguration: { 84 | ConfigurationSource: 'API', 85 | CodeConfigurationValues: { 86 | Runtime: config.runtime, 87 | BuildCommand: config.buildCommand, 88 | StartCommand: config.startCommand, 89 | Port: `${port}`, 90 | RuntimeEnvironmentVariables: runtimeEnvironmentVariables, 91 | RuntimeEnvironmentSecrets: runtimeEnvironmentSecrets, 92 | }, 93 | }, 94 | }, 95 | }; 96 | } 97 | 98 | function getImageSourceConfiguration(port: number, config: IImageConfiguration, runtimeEnvironmentVariables?: Record, runtimeEnvironmentSecrets?: Record): SourceConfiguration { 99 | return { 100 | AuthenticationConfiguration: { 101 | AccessRoleArn: config.accessRoleArn 102 | }, 103 | ImageRepository: { 104 | ImageIdentifier: config.imageUri, 105 | ImageRepositoryType: getImageType(config.imageUri), 106 | ImageConfiguration: { 107 | Port: `${port}`, 108 | RuntimeEnvironmentVariables: runtimeEnvironmentVariables, 109 | RuntimeEnvironmentSecrets: runtimeEnvironmentSecrets, 110 | } 111 | } 112 | }; 113 | } 114 | -------------------------------------------------------------------------------- /src/action-configuration.ts: -------------------------------------------------------------------------------- 1 | import { getInput, getMultilineInput, InputOptions } from "@actions/core"; 2 | import { Runtime, Tag } from "@aws-sdk/client-apprunner"; 3 | 4 | // supported GitHub action modes 5 | enum Actions { 6 | // Create a new service or update an existing one 7 | CreateOrUpdate = 'create_or_update', 8 | } 9 | 10 | export interface ICodeConfiguration { 11 | sourceType: 'code'; 12 | repoUrl: string; 13 | branch: string; 14 | sourceConnectionArn: string; 15 | runtime: Runtime; 16 | buildCommand: string; 17 | startCommand: string; 18 | } 19 | 20 | export interface IImageConfiguration { 21 | sourceType: 'image'; 22 | imageUri: string; 23 | accessRoleArn: string; 24 | } 25 | 26 | export interface ICreateOrUpdateActionParams { 27 | action: Actions.CreateOrUpdate; 28 | serviceName: string; 29 | sourceConfig: ICodeConfiguration | IImageConfiguration; 30 | port: number; 31 | waitForService: boolean; 32 | waitTimeout: number; 33 | region: string; 34 | cpu: number; 35 | memory: number; 36 | environment?: Record; 37 | environmentSecret?: Record; 38 | tags: Tag[] 39 | autoScalingConfigArn?: string; 40 | instanceRoleArn?: string; 41 | } 42 | 43 | export type IActionParams = ICreateOrUpdateActionParams; 44 | 45 | interface IValidationRules { 46 | min?: number; 47 | max?: number; 48 | } 49 | 50 | function getOptionalInputNumber(name: string, { validation, intOnly }: { validation?: IValidationRules, intOnly?: boolean } = {}): number | undefined { 51 | const val = getInput(name, { required: false, trimWhitespace: true }); 52 | if (!val) { 53 | return undefined; 54 | } 55 | 56 | const result = intOnly 57 | ? Number.parseInt(val) 58 | : Number.parseFloat(val); 59 | 60 | if (isNaN(result)) { 61 | throw new Error(`${name} value is not a valid number: ${val}`); 62 | } 63 | 64 | if (validation?.min && validation.min > result) { 65 | throw new Error(`${name} value (${result}) is less then the allowed minimum (${validation.min})`); 66 | } 67 | 68 | if (validation?.max && validation.max < result) { 69 | throw new Error(`${name} value (${result}) is greater then the allowed maximum (${validation.max})`); 70 | } 71 | 72 | return result; 73 | } 74 | 75 | function getInputNumber(name: string, defaultValue: number, { validation, intOnly }: { validation?: IValidationRules, intOnly?: boolean } = {}): number { 76 | return getOptionalInputNumber(name, { validation, intOnly }) ?? defaultValue; 77 | } 78 | 79 | function getInputStr(name: string, defaultValue: string): string { 80 | return getInput(name, { required: false, trimWhitespace: true }) || defaultValue; 81 | } 82 | 83 | function getOptionalInputStr(name: string, options?: InputOptions): string | undefined { 84 | const value = getInput(name, { required: false, ...options }) 85 | return (value.length > 0) ? value : undefined 86 | } 87 | 88 | function getInputBool(name: string, defaultValue: boolean): boolean { 89 | const val = getInput(name, { required: false, trimWhitespace: true }); 90 | if (!val) { 91 | return defaultValue; 92 | } 93 | 94 | return ['1', 'true'].includes(val.toLowerCase()); 95 | } 96 | 97 | export function getConfig(): IActionParams { 98 | const rawActionInput = getInput('action', { required: false, trimWhitespace: true }); 99 | 100 | switch (rawActionInput.toLowerCase() || Actions.CreateOrUpdate) { 101 | case Actions.CreateOrUpdate: 102 | return getCreateOrUpdateConfig(); 103 | default: 104 | throw new Error(`Unsupported action: ${rawActionInput}`); 105 | } 106 | } 107 | 108 | function getCreateOrUpdateConfig(): ICreateOrUpdateActionParams { 109 | const action = Actions.CreateOrUpdate; 110 | // Service name - required input with no default value 111 | const serviceName = getInput('service', { required: true, trimWhitespace: true }); 112 | 113 | // Port number - 80 114 | const port = getInputNumber('port', 80, { intOnly: true }); 115 | 116 | // Region - us-east-1 117 | const region = getInputStr('region', 'us-east-1'); 118 | 119 | // Wait for service to complete the creation/update - false 120 | const waitForService = getInputBool('wait-for-service-stability', false); 121 | const waitTimeout = getOptionalInputNumber('wait-for-service-stability-seconds', { validation: { min: 10, max: 3600 }, intOnly: true }); 122 | 123 | // CPU - 1 vCPU 124 | const cpu = getInputNumber('cpu', 1); 125 | 126 | // Memory - 2GB 127 | const memory = getInputNumber('memory', 2); 128 | 129 | // Source docker image URL - this will switch between deploying source code or docker image 130 | const imageUri = getInput('image', { required: false, trimWhitespace: true }); 131 | 132 | const envVarNames = getMultilineInput('copy-env-vars', { required: false }); 133 | 134 | const secretEnvVarNames = getMultilineInput('copy-secret-env-vars', { required: false }); 135 | 136 | const tags = getInput('tags', { required: false }) 137 | 138 | const autoScalingConfigArn = getOptionalInputStr('auto-scaling-config-arn', { trimWhitespace: true }); 139 | 140 | const instanceRoleArn = getOptionalInputStr('instance-role-arn', { trimWhitespace: true }); 141 | 142 | return { 143 | action, 144 | serviceName, 145 | region, 146 | port, 147 | waitForService: waitForService || !!waitTimeout, 148 | waitTimeout: waitTimeout ?? 600, 149 | cpu, 150 | memory, 151 | sourceConfig: imageUri ? getImageConfig(imageUri) : getSourceCodeConfig(), 152 | environment: getEnvironmentVariables(envVarNames), 153 | environmentSecret: getEnvironmentVariables(secretEnvVarNames), 154 | tags: getTags(tags), 155 | autoScalingConfigArn: autoScalingConfigArn, 156 | instanceRoleArn: instanceRoleArn, 157 | }; 158 | } 159 | 160 | function getBranch(): string { 161 | // Breaking change - default branch name switched to `main`!!! 162 | const branch = getInput('branch', { required: false }) || 'main'; 163 | 164 | return (branch.startsWith("refs/")) ? branch.split("/")[2] : branch; 165 | } 166 | 167 | function getImageConfig(imageUri: string): IImageConfiguration { 168 | if (getInput('repo', { required: false })) { 169 | throw new Error('Either docker image registry or code repository expected, not both'); 170 | } 171 | return { 172 | sourceType: 'image', 173 | imageUri, 174 | accessRoleArn: getInput('access-role-arn', { required: true }), 175 | }; 176 | } 177 | 178 | function getSourceCodeConfig(): ICodeConfiguration { 179 | return { 180 | sourceType: 'code', 181 | sourceConnectionArn: getInput('source-connection-arn', { required: true }), 182 | repoUrl: getInput('repo', { required: true }), 183 | branch: getBranch(), 184 | runtime: getRuntime(), 185 | buildCommand: getInput('build-command', { required: true }), 186 | startCommand: getInput('start-command', { required: true }), 187 | } 188 | } 189 | 190 | function getRuntime(): Runtime { 191 | const rawRuntime = getInput('runtime', { required: true }); 192 | const runtime = rawRuntime.toUpperCase(); 193 | if (!Object.keys(Runtime).includes(runtime)) { 194 | throw new Error(`Specified runtime (${rawRuntime}) does not belong to the supported range: ${JSON.stringify(Object.keys(Runtime))}`); 195 | } 196 | 197 | return Runtime[runtime]; 198 | } 199 | 200 | function getEnvironmentVariables(envVarNames: string[]): Record | undefined { 201 | if (envVarNames.length > 0) { 202 | const mapped = envVarNames.reduce((acc: Record, env) => { 203 | const envVarValue = process.env[env]; 204 | if (envVarValue !== undefined) { 205 | acc[env] = envVarValue; 206 | } 207 | return acc; 208 | }, {}); 209 | if (Object.keys(mapped).length > 0) { 210 | return mapped; 211 | } 212 | } 213 | } 214 | 215 | function getTags(tags: string): Tag[] { 216 | if (!tags.length) { 217 | return [] 218 | } 219 | 220 | const parsed = JSON.parse(tags); 221 | return Object.keys(parsed).reduce((acc, tagKey) => { 222 | return [ 223 | ...acc, 224 | { 225 | Key: tagKey, 226 | Value: parsed[tagKey] 227 | } 228 | ] 229 | }, [] as Tag[]) 230 | } 231 | -------------------------------------------------------------------------------- /src/action-helper-functions.ts: -------------------------------------------------------------------------------- 1 | import { info } from "@actions/core"; 2 | import { AppRunnerClient, ListServicesCommandOutput, Service, ServiceStatus, OperationStatus, UpdateServiceResponse, OperationSummary } from "@aws-sdk/client-apprunner"; 3 | import { IActionParams } from "./action-configuration"; 4 | import { getCreateCommand, getDeleteCommand, getDescribeCommand, getListCommand, getUpdateCommand, getTagResourceCommand, getListOperationsCommand } from "./client-apprunner-commands"; 5 | 6 | // Core service attributes to be returned to the calling GitHub action handler code 7 | export interface IServiceInfo { 8 | ServiceId: string; 9 | ServiceArn: string; 10 | ServiceUrl: string; 11 | OperationId?: string; 12 | } 13 | 14 | export interface IExistingService { 15 | ServiceArn: string; 16 | Status: ServiceStatus; 17 | } 18 | 19 | // Wait in milliseconds (helps to implement exponential retries) 20 | function sleep(ms: number): Promise { 21 | return new Promise(resolve => setTimeout(resolve, ms)); 22 | } 23 | 24 | // Get the existing service ARN or undefined, if there is no existing service 25 | export async function findExistingService(client: AppRunnerClient, serviceName: string): Promise { 26 | 27 | let nextToken: string | undefined = undefined; 28 | 29 | do { 30 | const listServiceResponse: ListServicesCommandOutput = await client.send(getListCommand(nextToken)); 31 | nextToken = listServiceResponse.NextToken; 32 | 33 | if (listServiceResponse.ServiceSummaryList) { 34 | for (const service of listServiceResponse.ServiceSummaryList) { 35 | if (service.ServiceName === serviceName && service.ServiceArn) { 36 | info(`Discovered ${serviceName} (${service.ServiceArn}) with the following status: ${service.Status}`); 37 | return { 38 | ServiceArn: service.ServiceArn, 39 | Status: service.Status as ServiceStatus, 40 | }; 41 | } 42 | } 43 | } 44 | } while (nextToken) 45 | } 46 | 47 | // Create a new service 48 | async function createService(client: AppRunnerClient, config: IActionParams): Promise { 49 | info(`Creating service ${config.serviceName}`); 50 | const command = getCreateCommand(config); 51 | const createServiceResponse = await client.send(command); 52 | return createServiceResponse.Service; 53 | } 54 | 55 | // Update an existing service 56 | async function updateService(client: AppRunnerClient, config: IActionParams, serviceArn: string): Promise { 57 | info(`Updating existing service ${config.serviceName} (${serviceArn})`); 58 | const command = getUpdateCommand(serviceArn, config); 59 | return await client.send(command); 60 | } 61 | 62 | async function updateTag(client: AppRunnerClient, config: IActionParams, serviceArn: string): Promise { 63 | info(`Updating tags service ${config.serviceName} (${serviceArn})`); 64 | if (config.tags.length) { 65 | const command = getTagResourceCommand(serviceArn, config); 66 | await client.send(command) 67 | } 68 | return 69 | } 70 | 71 | async function deleteService(client: AppRunnerClient, config: IActionParams, serviceArn: string): Promise { 72 | info(`Deleting existing service ${config.serviceName} (${serviceArn})`); 73 | const command = getDeleteCommand(serviceArn); 74 | const deleteServiceResponse = await client.send(command); 75 | info(`Delete service response: ${JSON.stringify(deleteServiceResponse.Service)}`); 76 | } 77 | 78 | async function listOperations(client: AppRunnerClient, serviceArn: string): Promise { 79 | const command = getListOperationsCommand(serviceArn); 80 | const listOperationsResponse = await client.send(command); 81 | return listOperationsResponse.OperationSummaryList 82 | } 83 | 84 | export async function validateAndExtractServiceInfo(config: IActionParams, service?: Service, operationId?: string): Promise { 85 | if (!service) { 86 | throw new Error(`Failed to create or update service ${config.serviceName} - App Runner Client returned an empty response`); 87 | } 88 | 89 | const serviceId = service.ServiceId; 90 | if (!serviceId) { 91 | throw new Error(`App Runner Client returned an empty ServiceId for ${config.serviceName}`); 92 | } else { 93 | info(`Service ID: ${serviceId}`); 94 | } 95 | 96 | const serviceArn = service.ServiceArn; 97 | if (!serviceArn) { 98 | throw new Error(`App Runner Client returned an empty ServiceArn for ${config.serviceName}`); 99 | } else { 100 | info(`Service ARN: ${serviceArn}`); 101 | } 102 | 103 | const serviceUrl = service.ServiceUrl ?? ""; 104 | if (serviceUrl === "") { 105 | info(`App Runner Client returned an empty ServiceUrl for ${config.serviceName}, this could happen if the deployment is of an AWS App Runner Private Service`); 106 | } else { 107 | info(`Service URL: ${serviceUrl}`); 108 | } 109 | 110 | return { 111 | ServiceId: serviceId, 112 | ServiceArn: serviceArn, 113 | ServiceUrl: serviceUrl, 114 | OperationId: operationId, 115 | }; 116 | } 117 | 118 | // Create or update an existing service, depending on whether it already exists 119 | export async function createOrUpdateService(client: AppRunnerClient, config: IActionParams, existingService?: IExistingService): Promise { 120 | let service: Service | undefined = undefined; 121 | let operationId: string | undefined = undefined; 122 | if (existingService) { 123 | info(`Existing service info: ${JSON.stringify(existingService)}`); 124 | if (existingService.Status === ServiceStatus.CREATE_FAILED) { 125 | await deleteService(client, config, existingService.ServiceArn); 126 | const status = await waitToStabilize(client, existingService.ServiceArn, 900); // wait for delete operation to complete in 15 minutes max 127 | if (status === ServiceStatus.DELETED) { 128 | service = await createService(client, config); 129 | } else { 130 | throw new Error(`Failed to delete service ${config.serviceName} (${existingService.ServiceArn}). Its current status is ${status}`); 131 | } 132 | } else { 133 | await updateTag(client, config, existingService.ServiceArn); 134 | const response = await updateService(client, config, existingService.ServiceArn); 135 | service = response.Service; 136 | operationId = response.OperationId; 137 | } 138 | } else { 139 | service = await createService(client, config); 140 | } 141 | 142 | return validateAndExtractServiceInfo(config, service, operationId); 143 | } 144 | 145 | async function describeService(client: AppRunnerClient, serviceArn: string): Promise { 146 | const describeServiceResponse = await client.send(getDescribeCommand(serviceArn)); 147 | 148 | const service = describeServiceResponse.Service; 149 | if(!service) { 150 | throw new Error(`App Runner Client returned an empty Service for ${serviceArn}`); 151 | } 152 | 153 | return { 154 | ServiceArn: serviceArn, 155 | Status: service.Status as ServiceStatus, 156 | }; 157 | } 158 | 159 | export async function checkOperationIsSucceeded(client: AppRunnerClient, serviceArn: string, operationId: string): Promise { 160 | const operations = await listOperations(client, serviceArn); 161 | if (operations) { 162 | for (const operation of operations) { 163 | if (operationId === operation.Id && operation.Status !== OperationStatus.SUCCEEDED) { 164 | throw new Error(`Operation ${operationId} is not successful. Its current status is ${operation.Status}`); 165 | } 166 | } 167 | } 168 | } 169 | 170 | // Wait for the service to reach a stable state 171 | export async function waitToStabilize(client: AppRunnerClient, serviceArn: string, timeoutSeconds: number): Promise { 172 | const stopTime = new Date(new Date().getTime() + timeoutSeconds * 1000).getTime(); 173 | 174 | let status: ServiceStatus = ServiceStatus.OPERATION_IN_PROGRESS; 175 | info(`Waiting for ${serviceArn} to reach stable state`); 176 | while (status === ServiceStatus.OPERATION_IN_PROGRESS && stopTime >= new Date().getTime()) { 177 | const startTime = new Date().getTime(); 178 | const describeServiceResponse = await describeService(client, serviceArn); 179 | 180 | status = describeServiceResponse.Status; 181 | if (status !== ServiceStatus.OPERATION_IN_PROGRESS) { 182 | info(`Service ${serviceArn} has reached the stable state ${status}`); 183 | return status; 184 | } 185 | 186 | const duration = new Date().getTime() - startTime; 187 | 188 | const idleTime = 1000 - duration; 189 | if (idleTime > 0) { 190 | // Wait for the rest of the second before the retry 191 | await sleep(idleTime); 192 | } 193 | } 194 | 195 | throw new Error(`Service ${serviceArn} did not reach stable state within ${timeoutSeconds} seconds`); 196 | } 197 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amazon "App Runner Service Deploy" action for GitHub Actions 2 | 3 | Registers an AWS App Runner Service and deploys the application using the source code of a given GitHub repository. Supports both source code and Docker image based service. 4 | 5 | ## Table of Contents 6 | 7 | 8 | 9 | - [V2 changes](#v2-changes) 10 | - [Usage](#usage) 11 | - [Code based service](#code-based-service) 12 | - [Image based service](#image-based-service) 13 | - [Credentials and Region](#credentials-and-region) 14 | - [Permissions](#permissions) 15 | - [Troubleshooting](#troubleshooting) 16 | - [License Summary](#license-summary) 17 | - [Security Disclosures](#security-disclosures) 18 | 19 | 20 | 21 | ## V2 Changes 22 | 23 | This action's codebase has been refactored to support future growth as well as to simplify to processes of adding new capabilities. 24 | 25 | Refer to the [Changelog](./CHANGELOG.md) for the change history. 26 | 27 | ## Usage 28 | 29 | This github action supports two types of App Runner services: source code based and docker image based. 30 | 31 | See [action.yml](action.yml) for the full documentation for this action's inputs and outputs. 32 | 33 | ### Code based service 34 | 35 | Source code is application code that App Runner builds and deploys for you. You point App Runner to a source code repository and choose a suitable runtime. App Runner builds an image that's based on the base image of the runtime and your application code. It then starts a service that runs a container based on this image. 36 | 37 | > Note: The list of supported runtime platforms is available [here](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-apprunner/enums/runtime.html). 38 | 39 | Here is the sample for deploying a NodeJS based service: 40 | 41 | ```yaml 42 | name: Deploy to App Runner 43 | on: 44 | push: 45 | branches: [main] # Trigger workflow on git push to main branch 46 | workflow_dispatch: # Allow manual invocation of the workflow 47 | 48 | jobs: 49 | deploy: 50 | runs-on: ubuntu-latest 51 | # These permissions are needed to interact with GitHub's OIDC Token endpoint. 52 | permissions: 53 | id-token: write 54 | contents: read 55 | 56 | steps: 57 | - name: Configure AWS credentials 58 | uses: aws-actions/configure-aws-credentials@v1-node16 59 | with: 60 | # Use GitHub OIDC provider 61 | role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} 62 | aws-region: ${{ secrets.AWS_REGION }} 63 | 64 | - name: Deploy to App Runner 65 | id: deploy-apprunner 66 | uses: awslabs/amazon-app-runner-deploy@main 67 | env: 68 | SERVER_PORT: 80 69 | SECRET_ENV: secret_env 70 | with: 71 | service: app-runner-git-deploy-service 72 | source-connection-arn: ${{ secrets.AWS_CONNECTION_SOURCE_ARN }} 73 | repo: https://github.com/${{ github.repository }} 74 | branch: ${{ github.ref }} 75 | runtime: NODEJS_16 76 | build-command: npm install 77 | start-command: npm start 78 | port: 18000 79 | region: ${{ secrets.AWS_REGION }} 80 | cpu : 1 81 | memory : 2 82 | # Deprecated: wait-for-service-stability: true 83 | # The new way to control service stability timeout 84 | wait-for-service-stability-seconds: 600 85 | copy-env-vars: | 86 | SERVER_PORT 87 | copy-secret-env-vars: | 88 | SECRET_ENV 89 | instance-role-arn: ${{ secrets.INSTANCE_ROLE_ARN }} 90 | tags: > 91 | { "env": "test" } 92 | 93 | - name: App Runner URL 94 | run: echo "App runner URL ${{ steps.deploy-apprunner.outputs.service-url }}" 95 | ``` 96 | 97 | **Note:** 98 | 99 | - **AWS_CONNECTION_SOURCE_ARN** is the ARN of the source code connector in AWS App Runner, for more details refer to this [documentation](https://docs.aws.amazon.com/apprunner/latest/dg/manage-connections.html) 100 | 101 | ### Image based service 102 | 103 | Here, a source image (*that could be a public or private container image stored in an image repository*) can get used by App Runner to get the service running on a container. No build stage is necessary. Rather, you provide a ready-to-deploy image. 104 | 105 | Here is the sample for deploying a App Runner service based on docker image: 106 | 107 | ```yaml 108 | name: Deploy to App Runner 109 | on: 110 | push: 111 | branches: [main] # Trigger workflow on git push to main branch 112 | workflow_dispatch: # Allow manual invocation of the workflow 113 | 114 | jobs: 115 | deploy: 116 | runs-on: ubuntu-latest 117 | # These permissions are needed to interact with GitHub's OIDC Token endpoint. 118 | permissions: 119 | id-token: write 120 | contents: read 121 | 122 | steps: 123 | - name: Checkout 124 | uses: actions/checkout@v2 125 | with: 126 | persist-credentials: false 127 | 128 | - name: Configure AWS credentials 129 | id: aws-credentials 130 | uses: aws-actions/configure-aws-credentials@v1-node16 131 | with: 132 | # Use GitHub OIDC provider 133 | role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} 134 | aws-region: ${{ secrets.AWS_REGION }} 135 | 136 | - name: Login to Amazon ECR 137 | id: login-ecr 138 | uses: aws-actions/amazon-ecr-login@v1 139 | 140 | - name: Build, tag, and push image to Amazon ECR 141 | id: build-image 142 | env: 143 | ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} 144 | ECR_REPOSITORY: nodejs 145 | IMAGE_TAG: ${{ github.sha }} 146 | run: | 147 | docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . 148 | docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG 149 | echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" 150 | 151 | - name: Deploy to App Runner Image 152 | id: deploy-apprunner 153 | uses: awslabs/amazon-app-runner-deploy@main 154 | with: 155 | service: app-runner-git-deploy-service 156 | image: ${{ steps.build-image.outputs.image }} 157 | access-role-arn: ${{ secrets.ROLE_ARN }} 158 | region: ${{ secrets.AWS_REGION }} 159 | cpu : 1 160 | memory : 2 161 | # Deprecated: wait-for-service-stability: true 162 | # The new way to control service stability timeout 163 | wait-for-service-stability-seconds: 1200 164 | 165 | - name: App Runner URL 166 | run: echo "App runner URL ${{ steps.deploy-apprunner.outputs.service-url }}" 167 | ``` 168 | 169 | **Note:** 170 | 171 | - The above example uses github action, to build the docker image, push it to AWS ECR and use the same for App Runner deployment 172 | - **ROLE_ARN** is the Amazon Resource Name (ARN) of the IAM role that grants the App Runner service access to a source repository. It's required for ECR image repositories (but not for ECR Public repositories) 173 | 174 | ## Credentials and Region 175 | 176 | This action relies on the [default behavior of the AWS SDK for Javascript](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/setting-credentials-node.html) to determine AWS credentials and region. 177 | Use [the `aws-actions/configure-aws-credentials` action](https://github.com/aws-actions/configure-aws-credentials) to configure the GitHub Actions environment with environment variables containing AWS credentials and your desired region. 178 | 179 | We recommend using [GitHub's OIDC provider](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services) to get short-lived credentials needed for your actions. 180 | 181 | It is recommended to follow [Amazon IAM best practices](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html) for the AWS credentials used in GitHub Actions workflows, including: 182 | 183 | - Do not store credentials in your repository's code. You may use [GitHub Actions secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) to store credentials and redact credentials from GitHub Actions workflow logs. 184 | - [Create an individual IAM user](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#create-iam-users) with an access key for use in GitHub Actions workflows, preferably one per repository. Do not use the AWS account root user access key. 185 | - [Grant least privilege](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege) to the credentials used in GitHub Actions workflows. Grant only the permissions required to perform the actions in your GitHub Actions workflows. See the Permissions section below for the permissions required by this action. 186 | - [Rotate the credentials](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials) used in GitHub Actions workflows regularly. 187 | - [Monitor the activity](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#keep-a-log) of the credentials used in GitHub Actions workflows. 188 | 189 | ## Permissions 190 | 191 | Generally this action requires the following minimum set of permissions: 192 | 193 | ```json 194 | { 195 | "Version": "2012-10-17", 196 | "Statement": [ 197 | { 198 | "Effect": "Allow", 199 | "Action": [ 200 | "apprunner:ListServices", 201 | "apprunner:CreateService", 202 | "apprunner:UpdateService", 203 | "apprunner:DescribeService", 204 | "apprunner:TagResource", 205 | "iam:PassRole" 206 | ], 207 | "Resource": "*" 208 | } 209 | ] 210 | } 211 | ``` 212 | 213 | For **Image based service** this action requires additionally the following minimum set of permissions: 214 | 215 | ```json 216 | { 217 | "Version": "2012-10-17", 218 | "Statement": [ 219 | { 220 | "Effect": "Allow", 221 | "Action": [ 222 | "ecr:GetDownloadUrlForLayer", 223 | "ecr:BatchGetImage", 224 | "ecr:DescribeImages", 225 | "ecr:GetAuthorizationToken", 226 | "ecr:BatchCheckLayerAvailability" 227 | ], 228 | "Resource": "*" 229 | } 230 | ] 231 | } 232 | ``` 233 | 234 | ## Troubleshooting 235 | 236 | 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. 237 | 238 | ## License Summary 239 | 240 | This code is made available under the MIT-0 license, for details refer to [LICENSE](LICENSE) file. 241 | 242 | ## Security Disclosures 243 | 244 | 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). 245 | -------------------------------------------------------------------------------- /THIRD-PARTY: -------------------------------------------------------------------------------- 1 | ** AWS SDK for JavaScript; version 2.562.0 -- https://github.com/aws/aws-sdk-js 2 | Copyright 2012-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | Apache License 5 | 6 | Version 2.0, January 2004 7 | 8 | http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND 9 | DISTRIBUTION 10 | 11 | 1. Definitions. 12 | 13 | "License" shall mean the terms and conditions for use, reproduction, and 14 | distribution as defined by Sections 1 through 9 of this document. 15 | 16 | "Licensor" shall mean the copyright owner or entity authorized by the 17 | copyright owner that is granting the License. 18 | 19 | "Legal Entity" shall mean the union of the acting entity and all other 20 | entities that control, are controlled by, or are under common control 21 | with that entity. For the purposes of this definition, "control" means 22 | (i) the power, direct or indirect, to cause the direction or management 23 | of such entity, whether by contract or otherwise, or (ii) ownership of 24 | fifty percent (50%) or more of the outstanding shares, or (iii) 25 | beneficial ownership of such entity. 26 | 27 | "You" (or "Your") shall mean an individual or Legal Entity exercising 28 | permissions granted by this License. 29 | 30 | "Source" form shall mean the preferred form for making modifications, 31 | including but not limited to software source code, documentation source, 32 | and configuration files. 33 | 34 | "Object" form shall mean any form resulting from mechanical 35 | transformation or translation of a Source form, including but not limited 36 | to compiled object code, generated documentation, and conversions to 37 | other media types. 38 | 39 | "Work" shall mean the work of authorship, whether in Source or Object 40 | form, made available under the License, as indicated by a copyright 41 | notice that is included in or attached to the work (an example is 42 | provided in the Appendix below). 43 | 44 | "Derivative Works" shall mean any work, whether in Source or Object form, 45 | that is based on (or derived from) the Work and for which the editorial 46 | revisions, annotations, elaborations, or other modifications represent, 47 | as a whole, an original work of authorship. For the purposes of this 48 | License, Derivative Works shall not include works that remain separable 49 | from, or merely link (or bind by name) to the interfaces of, the Work and 50 | Derivative Works thereof. 51 | 52 | "Contribution" shall mean any work of authorship, including the original 53 | version of the Work and any modifications or additions to that Work or 54 | Derivative Works thereof, that is intentionally submitted to Licensor for 55 | inclusion in the Work by the copyright owner or by an individual or Legal 56 | Entity authorized to submit on behalf of the copyright owner. For the 57 | purposes of this definition, "submitted" means any form of electronic, 58 | verbal, or written communication sent to the Licensor or its 59 | representatives, including but not limited to communication on electronic 60 | mailing lists, source code control systems, and issue tracking systems 61 | that are managed by, or on behalf of, the Licensor for the purpose of 62 | discussing and improving the Work, but excluding communication that is 63 | conspicuously marked or otherwise designated in writing by the copyright 64 | owner as "Not a Contribution." 65 | 66 | "Contributor" shall mean Licensor and any individual or Legal Entity on 67 | behalf of whom a Contribution has been received by Licensor and 68 | subsequently incorporated within the Work. 69 | 70 | 2. Grant of Copyright License. Subject to the terms and conditions of this 71 | License, each Contributor hereby grants to You a perpetual, worldwide, 72 | non-exclusive, no-charge, royalty-free, irrevocable copyright license to 73 | reproduce, prepare Derivative Works of, publicly display, publicly perform, 74 | sublicense, and distribute the Work and such Derivative Works in Source or 75 | Object form. 76 | 77 | 3. Grant of Patent License. Subject to the terms and conditions of this 78 | License, each Contributor hereby grants to You a perpetual, worldwide, 79 | non-exclusive, no-charge, royalty-free, irrevocable (except as stated in 80 | this section) patent license to make, have made, use, offer to sell, sell, 81 | import, and otherwise transfer the Work, where such license applies only to 82 | those patent claims licensable by such Contributor that are necessarily 83 | infringed by their Contribution(s) alone or by combination of their 84 | Contribution(s) with the Work to which such Contribution(s) was submitted. 85 | If You institute patent litigation against any entity (including a 86 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 87 | Contribution incorporated within the Work constitutes direct or contributory 88 | patent infringement, then any patent licenses granted to You under this 89 | License for that Work shall terminate as of the date such litigation is 90 | filed. 91 | 92 | 4. Redistribution. You may reproduce and distribute copies of the Work or 93 | Derivative Works thereof in any medium, with or without modifications, and 94 | in Source or Object form, provided that You meet the following conditions: 95 | 96 | (a) You must give any other recipients of the Work or Derivative Works a 97 | copy of this License; and 98 | 99 | (b) You must cause any modified files to carry prominent notices stating 100 | that You changed the files; and 101 | 102 | (c) You must retain, in the Source form of any Derivative Works that You 103 | distribute, all copyright, patent, trademark, and attribution notices 104 | from the Source form of the Work, excluding those notices that do not 105 | pertain to any part of the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must include 109 | a readable copy of the attribution notices contained within such NOTICE 110 | file, excluding those notices that do not pertain to any part of the 111 | Derivative Works, in at least one of the following places: within a 112 | NOTICE text file distributed as part of the Derivative Works; within the 113 | Source form or documentation, if provided along with the Derivative 114 | Works; or, within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents of the 116 | NOTICE file are for informational purposes only and do not modify the 117 | License. You may add Your own attribution notices within Derivative Works 118 | that You distribute, alongside or as an addendum to the NOTICE text from 119 | the Work, provided that such additional attribution notices cannot be 120 | construed as modifying the License. 121 | 122 | You may add Your own copyright statement to Your modifications and may 123 | provide additional or different license terms and conditions for use, 124 | reproduction, or distribution of Your modifications, or for any such 125 | Derivative Works as a whole, provided Your use, reproduction, and 126 | distribution of the Work otherwise complies with the conditions stated in 127 | this License. 128 | 129 | 5. Submission of Contributions. Unless You explicitly state otherwise, any 130 | Contribution intentionally submitted for inclusion in the Work by You to the 131 | Licensor shall be under the terms and conditions of this License, without 132 | any additional terms or conditions. Notwithstanding the above, nothing 133 | herein shall supersede or modify the terms of any separate license agreement 134 | you may have executed with Licensor regarding such Contributions. 135 | 136 | 6. Trademarks. This License does not grant permission to use the trade 137 | names, trademarks, service marks, or product names of the Licensor, except 138 | as required for reasonable and customary use in describing the origin of the 139 | Work and reproducing the content of the NOTICE file. 140 | 141 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in 142 | writing, Licensor provides the Work (and each Contributor provides its 143 | Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 144 | KIND, either express or implied, including, without limitation, any 145 | warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or 146 | FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining 147 | the appropriateness of using or redistributing the Work and assume any risks 148 | associated with Your exercise of permissions under this License. 149 | 150 | 8. Limitation of Liability. In no event and under no legal theory, whether 151 | in tort (including negligence), contract, or otherwise, unless required by 152 | applicable law (such as deliberate and grossly negligent acts) or agreed to 153 | in writing, shall any Contributor be liable to You for damages, including 154 | any direct, indirect, special, incidental, or consequential damages of any 155 | character arising as a result of this License or out of the use or inability 156 | to use the Work (including but not limited to damages for loss of goodwill, 157 | work stoppage, computer failure or malfunction, or any and all other 158 | commercial damages or losses), even if such Contributor has been advised of 159 | the possibility of such damages. 160 | 161 | 9. Accepting Warranty or Additional Liability. While redistributing the Work 162 | or Derivative Works thereof, You may choose to offer, and charge a fee for, 163 | acceptance of support, warranty, indemnity, or other liability obligations 164 | and/or rights consistent with this License. However, in accepting such 165 | obligations, You may act only on Your own behalf and on Your sole 166 | responsibility, not on behalf of any other Contributor, and only if You 167 | agree to indemnify, defend, and hold each Contributor harmless for any 168 | liability incurred by, or claims asserted against, such Contributor by 169 | reason of your accepting any such warranty or additional liability. END OF 170 | TERMS AND CONDITIONS 171 | 172 | APPENDIX: How to apply the Apache License to your work. 173 | 174 | To apply the Apache License to your work, attach the following boilerplate 175 | notice, with the fields enclosed by brackets "[]" replaced with your own 176 | identifying information. (Don't include the brackets!) The text should be 177 | enclosed in the appropriate comment syntax for the file format. We also 178 | recommend that a file or class name and description of purpose be included on 179 | the same "printed page" as the copyright notice for easier identification 180 | within third-party archives. 181 | 182 | Copyright [yyyy] [name of copyright owner] 183 | 184 | Licensed under the Apache License, Version 2.0 (the "License"); 185 | 186 | you may not use this file except in compliance with the License. 187 | 188 | You may obtain a copy of the License at 189 | 190 | http://www.apache.org/licenses/LICENSE-2.0 191 | 192 | Unless required by applicable law or agreed to in writing, software 193 | 194 | distributed under the License is distributed on an "AS IS" BASIS, 195 | 196 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 197 | 198 | See the License for the specific language governing permissions and 199 | 200 | limitations under the License. 201 | 202 | * For AWS SDK for JavaScript see also this required NOTICE: 203 | Copyright 2012-2018 Amazon.com, Inc. or its affiliates. All Rights 204 | Reserved. 205 | 206 | ------ 207 | 208 | ** GitHub Actions Toolkit; version 1.2.0 -- https://github.com/actions/toolkit 209 | Copyright 2019 GitHub 210 | 211 | MIT License 212 | 213 | Copyright (c) 214 | 215 | Permission is hereby granted, free of charge, to any person obtaining a copy of 216 | this software and associated documentation files (the "Software"), to deal in 217 | the Software without restriction, including without limitation the rights to 218 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 219 | of the Software, and to permit persons to whom the Software is furnished to do 220 | so, subject to the following conditions: 221 | 222 | The above copyright notice and this permission notice shall be included in all 223 | copies or substantial portions of the Software. 224 | 225 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 226 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 227 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 228 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 229 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 230 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 231 | SOFTWARE. 232 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | /* eslint no-unused-vars: ["warn", { "argsIgnorePattern": "^_" }] */ 3 | 4 | import { jest, expect, test, describe } from '@jest/globals'; 5 | import { getInput, getMultilineInput, info, setFailed, setOutput } from '@actions/core'; 6 | import { run } from '.'; 7 | import { getConfig } from './action-configuration'; 8 | import { FakeInput, FakeMultilineInput, getFakeInput, getFakeMultilineInput } from './test-helpers/fake-input'; 9 | import { AppRunnerClient, CreateServiceCommand, DeleteServiceCommand, DescribeServiceCommand, ImageRepositoryType, ListServicesCommand, ServiceStatus, UpdateServiceCommand, TagResourceCommand, ListOperationsCommand, OperationStatus } from '@aws-sdk/client-apprunner'; 10 | 11 | jest.mock('@actions/core'); 12 | 13 | const SERVICE_ID = "serviceId"; 14 | const SERVICE_URL = "xxxxx.awsapprunner.com"; 15 | const SERVICE_NAME = "serviceName"; 16 | const SERVICE_ARN = "serviceArn"; 17 | const SOURCE_ARN_CONNECTION = "sourceArnConnection"; 18 | const ACCESS_ROLE_ARN = "accessRoleArn"; 19 | const INSTANCE_ROLE_ARN = "instanceRoleArn"; 20 | const AUTO_SCALING_CONFIG_ARN = "autoScalingConfigArn"; 21 | const REPO = "repo"; 22 | const PUBLIC_DOCKER_IMAGE = "public.ecr.aws/bitnami/node:latest"; 23 | const RUNTIME = "NODEJS_16"; 24 | const BUILD_COMMAND = "build-command"; 25 | const START_COMMAND = "start-command"; 26 | const PORT = "80"; 27 | const DEFAULT_REGION = 'us-east-1'; 28 | const TAGS = '{ "env": "test" }' 29 | const OPERATION_ID = "test-operation-id"; 30 | 31 | const mockSendDef = jest.fn(); 32 | jest.mock('@aws-sdk/client-apprunner', () => { 33 | return { 34 | ...jest.requireActual('@aws-sdk/client-apprunner') as Record, 35 | AppRunnerClient: jest.fn(() => { 36 | return { 37 | send: mockSendDef, 38 | }; 39 | }), 40 | } 41 | }); 42 | 43 | describe('Input Validation', () => { 44 | const getInputMock = jest.mocked(getInput); 45 | const setFailedMock = jest.mocked(setFailed); 46 | 47 | test('cpu must be a number', async () => { 48 | const inputConfig: FakeInput = { 49 | service: SERVICE_NAME, 50 | "source-connection-arn": SOURCE_ARN_CONNECTION, 51 | "access-role-arn": ACCESS_ROLE_ARN, 52 | repo: REPO, 53 | image: PUBLIC_DOCKER_IMAGE, 54 | cpu: 'not-a-number', 55 | }; 56 | 57 | getInputMock.mockImplementation((name) => { 58 | return getFakeInput(inputConfig, name); 59 | }); 60 | 61 | await run(); 62 | expect(setFailedMock).toHaveBeenCalledWith('cpu value is not a valid number: not-a-number'); 63 | }); 64 | 65 | test('cpu and memory are allowed a float', async () => { 66 | const getMultilineInputMock = jest.mocked(getMultilineInput); 67 | const inputConfig: FakeInput = { 68 | cpu: '0.25', 69 | memory: '0.5', 70 | service: SERVICE_NAME, 71 | "source-connection-arn": SOURCE_ARN_CONNECTION, 72 | "access-role-arn": ACCESS_ROLE_ARN, 73 | "instance-role-arn": INSTANCE_ROLE_ARN, 74 | repo: REPO, 75 | runtime: RUNTIME, 76 | "build-command": BUILD_COMMAND, 77 | "start-command": START_COMMAND, 78 | }; 79 | 80 | getMultilineInputMock.mockImplementation((name) => { 81 | return getFakeMultilineInput({} as FakeMultilineInput, name); 82 | }); 83 | 84 | getInputMock.mockImplementation((name) => { 85 | return getFakeInput(inputConfig, name); 86 | }); 87 | 88 | const config = getConfig() 89 | expect(config.cpu).toEqual(0.25) 90 | expect(config.memory).toEqual(0.5) 91 | }); 92 | 93 | test('port and wait-for-service-stability must be int', async () => { 94 | const getMultilineInputMock = jest.mocked(getMultilineInput); 95 | const inputConfig: FakeInput = { 96 | cpu: '0.25', 97 | memory: '0.5', 98 | service: SERVICE_NAME, 99 | "source-connection-arn": SOURCE_ARN_CONNECTION, 100 | "access-role-arn": ACCESS_ROLE_ARN, 101 | "instance-role-arn": INSTANCE_ROLE_ARN, 102 | repo: REPO, 103 | runtime: RUNTIME, 104 | "build-command": BUILD_COMMAND, 105 | "start-command": START_COMMAND, 106 | port: '2.5', 107 | 'wait-for-service-stability-seconds': '12.5', 108 | }; 109 | 110 | getMultilineInputMock.mockImplementation((name) => { 111 | return getFakeMultilineInput({} as FakeMultilineInput, name); 112 | }); 113 | 114 | getInputMock.mockImplementation((name) => { 115 | return getFakeInput(inputConfig, name); 116 | }); 117 | 118 | const config = getConfig() 119 | expect(config.port).toEqual(2) 120 | expect(config.waitTimeout).toEqual(12) 121 | }); 122 | 123 | test('Both Docker image and source code repo provided', async () => { 124 | const inputConfig: FakeInput = { 125 | service: SERVICE_NAME, 126 | "source-connection-arn": SOURCE_ARN_CONNECTION, 127 | "access-role-arn": ACCESS_ROLE_ARN, 128 | repo: REPO, 129 | image: PUBLIC_DOCKER_IMAGE, 130 | }; 131 | 132 | getInputMock.mockImplementation((name) => { 133 | return getFakeInput(inputConfig, name); 134 | }); 135 | 136 | await run(); 137 | 138 | expect(setFailedMock).toHaveBeenCalledWith('Either docker image registry or code repository expected, not both'); 139 | }); 140 | 141 | test('Start command missing', async () => { 142 | const inputConfig: FakeInput = { 143 | service: SERVICE_NAME, 144 | "source-connection-arn": SOURCE_ARN_CONNECTION, 145 | "access-role-arn": ACCESS_ROLE_ARN, 146 | repo: REPO, 147 | runtime: RUNTIME, 148 | "build-command": BUILD_COMMAND, 149 | port: PORT, 150 | "wait-for-service-stability": 'true', 151 | }; 152 | 153 | getInputMock.mockImplementation((name, options) => { 154 | return getFakeInput(inputConfig, name, options); 155 | }); 156 | 157 | await run(); 158 | 159 | expect(setFailedMock).toHaveBeenCalledWith('start-command is required'); 160 | }); 161 | 162 | test('Invalid runtime', async () => { 163 | const inputConfig: FakeInput = { 164 | service: SERVICE_NAME, 165 | "source-connection-arn": SOURCE_ARN_CONNECTION, 166 | "access-role-arn": ACCESS_ROLE_ARN, 167 | repo: REPO, 168 | runtime: "RUNTIME", 169 | "build-command": BUILD_COMMAND, 170 | "start-command": START_COMMAND, 171 | port: PORT, 172 | "wait-for-service-stability": 'true', 173 | }; 174 | 175 | getInputMock.mockImplementation((name) => { 176 | return getFakeInput(inputConfig, name); 177 | }); 178 | 179 | await run(); 180 | 181 | expect(setFailedMock).toHaveBeenCalledWith(expect.stringContaining('Specified runtime (RUNTIME) does not belong to the supported range')); 182 | }); 183 | 184 | test('IAM Role missing', async () => { 185 | const inputConfig: FakeInput = { 186 | service: SERVICE_NAME, 187 | image: PUBLIC_DOCKER_IMAGE, 188 | }; 189 | 190 | getInputMock.mockImplementation((name, options) => { 191 | return getFakeInput(inputConfig, name, options); 192 | }); 193 | 194 | await run(); 195 | 196 | expect(setFailedMock).toHaveBeenCalledWith('access-role-arn is required'); 197 | }); 198 | 199 | test.each([ 200 | { value: 1, message: 'wait-for-service-stability-seconds value (1) is less then the allowed minimum (10)' }, 201 | { value: 10_000, message: 'wait-for-service-stability-seconds value (10000) is greater then the allowed maximum (3600)' }, 202 | ])('Invalid timeout value', async (arg: { value: number, message: string }) => { 203 | const inputConfig: FakeInput = { 204 | service: SERVICE_NAME, 205 | "access-role-arn": ACCESS_ROLE_ARN, 206 | image: PUBLIC_DOCKER_IMAGE, 207 | 'wait-for-service-stability-seconds': `${arg.value}`, 208 | }; 209 | 210 | getInputMock.mockImplementation((name, options) => { 211 | return getFakeInput(inputConfig, name, options); 212 | }); 213 | 214 | await run(); 215 | 216 | expect(setFailedMock).toHaveBeenCalledWith(arg.message); 217 | 218 | }); 219 | 220 | test('Unsupported action', async () => { 221 | const inputConfig: FakeInput = { 222 | action: 'DO_NOTHING', 223 | }; 224 | 225 | getInputMock.mockImplementation((name, options) => { 226 | return getFakeInput(inputConfig, name, options); 227 | }); 228 | 229 | await run(); 230 | 231 | expect(setFailedMock).toHaveBeenCalledWith('Unsupported action: DO_NOTHING'); 232 | }); 233 | }); 234 | 235 | describe('Exception Handling', () => { 236 | const getInputMock = jest.mocked(getInput); 237 | const setFailedMock = jest.mocked(setFailed); 238 | 239 | test('unhandled exception object logged as JSON', async () => { 240 | getInputMock.mockImplementation(() => { 241 | throw { 242 | Data: "some custom data", 243 | SomeValue: 123, 244 | }; 245 | }); 246 | 247 | await run(); 248 | 249 | expect(setFailedMock).toHaveBeenCalledWith("{\"Data\":\"some custom data\",\"SomeValue\":123}"); 250 | }); 251 | 252 | }); 253 | 254 | describe('Deploy to AppRunner', () => { 255 | 256 | const getInputMock = jest.mocked(getInput); 257 | const getMultilineInputMock = jest.mocked(getMultilineInput); 258 | const setFailedMock = jest.mocked(setFailed); 259 | const setOutputMock = jest.mocked(setOutput); 260 | const infoMock = jest.mocked(info); 261 | const appRunnerClientMock = jest.mocked(AppRunnerClient); 262 | 263 | const originalEnv = process.env; 264 | 265 | beforeEach(() => { 266 | jest.resetModules(); 267 | process.env = { 268 | ...originalEnv, 269 | TEST_ENV_VAR: 'test env var value', 270 | TEST_SECRET_ENV_VAR: '/test/secret_env' 271 | }; 272 | }); 273 | afterEach(() => { 274 | process.env = originalEnv; 275 | }); 276 | 277 | test('register app runner with source code configuration', async () => { 278 | const inputConfig: FakeInput = { 279 | service: SERVICE_NAME, 280 | "source-connection-arn": SOURCE_ARN_CONNECTION, 281 | "access-role-arn": ACCESS_ROLE_ARN, 282 | "instance-role-arn": INSTANCE_ROLE_ARN, 283 | repo: REPO, 284 | runtime: RUNTIME, 285 | "build-command": BUILD_COMMAND, 286 | "start-command": START_COMMAND, 287 | port: PORT, 288 | "wait-for-service-stability": 'false', 289 | tags: TAGS, 290 | }; 291 | 292 | const multiLineInputConfig: FakeMultilineInput = { 293 | 'copy-env-vars': ['TEST_ENV_VAR'], 294 | 'copy-secret-env-vars': ['TEST_SECRET_ENV_VAR'], 295 | } 296 | 297 | getInputMock.mockImplementation((name) => { 298 | return getFakeInput(inputConfig, name); 299 | }); 300 | getMultilineInputMock.mockImplementation((name) => { 301 | return getFakeMultilineInput(multiLineInputConfig, name); 302 | }); 303 | 304 | mockSendDef.mockImplementationOnce(async (cmd: ListServicesCommand) => { 305 | expect(cmd.input.NextToken).toBeUndefined(); 306 | return ({ NextToken: undefined, ServiceSummaryList: [] }); 307 | }); 308 | 309 | mockSendDef.mockImplementationOnce(async (cmd: CreateServiceCommand) => { 310 | expect(cmd.input).toMatchObject({ 311 | ServiceName: SERVICE_NAME, // create command requires service name 312 | InstanceConfiguration: { 313 | Cpu: `1 vCPU`, 314 | Memory: `2 GB`, 315 | InstanceRoleArn: INSTANCE_ROLE_ARN, 316 | }, 317 | SourceConfiguration: { 318 | AuthenticationConfiguration: { 319 | ConnectionArn: SOURCE_ARN_CONNECTION, 320 | }, 321 | AutoDeploymentsEnabled: true, 322 | CodeRepository: { 323 | RepositoryUrl: REPO, 324 | SourceCodeVersion: { 325 | Type: 'BRANCH', 326 | Value: 'main', 327 | }, 328 | CodeConfiguration: { 329 | ConfigurationSource: 'API', 330 | CodeConfigurationValues: { 331 | Runtime: RUNTIME, 332 | BuildCommand: BUILD_COMMAND, 333 | StartCommand: START_COMMAND, 334 | Port: PORT, 335 | RuntimeEnvironmentVariables: { 336 | TEST_ENV_VAR: 'test env var value', 337 | }, 338 | RuntimeEnvironmentSecrets: { 339 | TEST_SECRET_ENV_VAR: '/test/secret_env' 340 | } 341 | }, 342 | }, 343 | }, 344 | }, 345 | Tags: [{ Key: 'env', Value: 'test' }], 346 | }); 347 | return ({ Service: { ServiceId: SERVICE_ID, ServiceArn: SERVICE_ARN, ServiceUrl: SERVICE_URL } }); 348 | }); 349 | 350 | await run(); 351 | 352 | expect(setFailedMock).not.toHaveBeenCalled(); 353 | expect(appRunnerClientMock.mock.calls).toHaveLength(1); 354 | expect(appRunnerClientMock.mock.calls[0][0]).toMatchObject({ region: DEFAULT_REGION }); 355 | expect(setOutputMock).toHaveBeenNthCalledWith(1, 'service-id', SERVICE_ID); 356 | expect(setOutputMock).toHaveBeenNthCalledWith(2, 'service-arn', SERVICE_ARN); 357 | expect(setOutputMock).toHaveBeenNthCalledWith(3, 'service-url', SERVICE_URL); 358 | expect(infoMock).toBeCalledWith(`Service ${SERVICE_ID} has started an update. Watch for its progress in the AppRunner console`); 359 | }); 360 | 361 | test('register app runner with non-default configuration', async () => { 362 | const inputConfig: FakeInput = { 363 | service: SERVICE_NAME, 364 | 'source-connection-arn': SOURCE_ARN_CONNECTION, 365 | 'access-role-arn': ACCESS_ROLE_ARN, 366 | repo: REPO, 367 | runtime: 'PYTHON_3', 368 | 'build-command': BUILD_COMMAND, 369 | 'start-command': START_COMMAND, 370 | port: '8443', 371 | region: 'us-west-2', 372 | branch: 'refs/head/release', 373 | cpu: '3', 374 | memory: '5', 375 | tags: TAGS, 376 | 'auto-scaling-config-arn': AUTO_SCALING_CONFIG_ARN, 377 | }; 378 | 379 | getInputMock.mockImplementation((name) => { 380 | return getFakeInput(inputConfig, name); 381 | }); 382 | getMultilineInputMock.mockImplementation((name) => { 383 | return getFakeMultilineInput({} as FakeMultilineInput, name); 384 | }); 385 | 386 | mockSendDef.mockImplementationOnce(async (cmd: ListServicesCommand) => { 387 | expect(cmd.input.NextToken).toBeUndefined(); 388 | return ({ 389 | NextToken: undefined, 390 | ServiceSummaryList: [] 391 | }); 392 | }); 393 | mockSendDef.mockImplementationOnce(async (cmd: CreateServiceCommand) => { 394 | expect(cmd.input).toMatchObject({ 395 | ServiceName: SERVICE_NAME, // create command requires service name 396 | InstanceConfiguration: { 397 | Cpu: `3 vCPU`, 398 | Memory: `5 GB`, 399 | }, 400 | AutoScalingConfigurationArn: AUTO_SCALING_CONFIG_ARN, 401 | SourceConfiguration: { 402 | AuthenticationConfiguration: { 403 | ConnectionArn: SOURCE_ARN_CONNECTION, 404 | }, 405 | AutoDeploymentsEnabled: true, 406 | CodeRepository: { 407 | RepositoryUrl: REPO, 408 | SourceCodeVersion: { 409 | Type: 'BRANCH', 410 | Value: 'release', 411 | }, 412 | CodeConfiguration: { 413 | ConfigurationSource: 'API', 414 | CodeConfigurationValues: { 415 | Runtime: 'PYTHON_3', 416 | BuildCommand: BUILD_COMMAND, 417 | StartCommand: START_COMMAND, 418 | Port: '8443', 419 | RuntimeEnvironmentVariables: undefined, 420 | RuntimeEnvironmentSecrets: undefined, 421 | }, 422 | }, 423 | }, 424 | }, 425 | Tags: [{ Key: 'env', Value: 'test' }], 426 | }); 427 | return ({ Service: { ServiceId: SERVICE_ID, ServiceArn: SERVICE_ARN, ServiceUrl: SERVICE_URL } }); 428 | }); 429 | 430 | await run(); 431 | 432 | expect(setFailedMock).not.toHaveBeenCalled(); 433 | expect(appRunnerClientMock.mock.calls).toHaveLength(1); 434 | expect(appRunnerClientMock.mock.calls[0][0]).toMatchObject({ region: 'us-west-2' }); 435 | expect(setOutputMock).toHaveBeenNthCalledWith(1, 'service-id', SERVICE_ID); 436 | expect(setOutputMock).toHaveBeenNthCalledWith(2, 'service-arn', SERVICE_ARN); 437 | expect(setOutputMock).toHaveBeenNthCalledWith(3, 'service-url', SERVICE_URL); 438 | expect(infoMock).toBeCalledWith(`Service ${SERVICE_ID} has started an update. Watch for its progress in the AppRunner console`); 439 | }); 440 | 441 | test('register app and wait for stable state', async () => { 442 | const inputConfig: FakeInput = { 443 | service: SERVICE_NAME, 444 | "source-connection-arn": SOURCE_ARN_CONNECTION, 445 | "access-role-arn": ACCESS_ROLE_ARN, 446 | repo: REPO, 447 | runtime: RUNTIME, 448 | "build-command": BUILD_COMMAND, 449 | "start-command": START_COMMAND, 450 | port: PORT, 451 | "wait-for-service-stability": 'true', 452 | }; 453 | 454 | getInputMock.mockImplementation((name) => { 455 | return getFakeInput(inputConfig, name); 456 | }); 457 | getMultilineInputMock.mockImplementation((name) => { 458 | return getFakeMultilineInput({} as FakeMultilineInput, name); 459 | }); 460 | 461 | mockSendDef.mockImplementationOnce(async (cmd: ListServicesCommand) => { 462 | expect(cmd.input.NextToken).toBeUndefined(); 463 | return ({ 464 | NextToken: undefined, 465 | ServiceSummaryList: [] 466 | }); 467 | }); 468 | mockSendDef.mockImplementationOnce(async (cmd: CreateServiceCommand) => { 469 | expect(cmd.input).toMatchObject({ 470 | ServiceName: SERVICE_NAME, // create command requires service name 471 | InstanceConfiguration: { 472 | Cpu: `1 vCPU`, 473 | Memory: `2 GB`, 474 | }, 475 | SourceConfiguration: { 476 | AuthenticationConfiguration: { 477 | ConnectionArn: SOURCE_ARN_CONNECTION, 478 | }, 479 | AutoDeploymentsEnabled: true, 480 | CodeRepository: { 481 | RepositoryUrl: REPO, 482 | SourceCodeVersion: { 483 | Type: 'BRANCH', 484 | Value: 'main', 485 | }, 486 | CodeConfiguration: { 487 | ConfigurationSource: 'API', 488 | CodeConfigurationValues: { 489 | Runtime: RUNTIME, 490 | BuildCommand: BUILD_COMMAND, 491 | StartCommand: START_COMMAND, 492 | Port: PORT, 493 | RuntimeEnvironmentVariables: undefined, 494 | RuntimeEnvironmentSecrets: undefined, 495 | }, 496 | }, 497 | }, 498 | }, 499 | }); 500 | return ({ Service: { ServiceId: SERVICE_ID, ServiceArn: SERVICE_ARN, ServiceUrl: SERVICE_URL } }); 501 | }); 502 | mockSendDef.mockImplementationOnce(async (cmd: DescribeServiceCommand) => { 503 | expect(cmd.input).toMatchObject({ 504 | ServiceArn: SERVICE_ARN, 505 | }); 506 | return { Service: { Status: ServiceStatus.RUNNING } }; 507 | }); 508 | 509 | await run(); 510 | 511 | expect(setFailedMock).not.toHaveBeenCalled(); 512 | expect(appRunnerClientMock.mock.calls).toHaveLength(1); 513 | expect(appRunnerClientMock.mock.calls[0][0]).toMatchObject({ region: DEFAULT_REGION }); 514 | expect(setOutputMock).toHaveBeenNthCalledWith(1, 'service-id', SERVICE_ID); 515 | expect(setOutputMock).toHaveBeenNthCalledWith(2, 'service-arn', SERVICE_ARN); 516 | expect(setOutputMock).toHaveBeenNthCalledWith(3, 'service-url', SERVICE_URL); 517 | expect(infoMock).toBeCalledWith(`Waiting for ${SERVICE_ARN} to reach stable state`); 518 | expect(infoMock).toBeCalledWith(`Service ${SERVICE_ARN} has reached the stable state ${ServiceStatus.RUNNING}`); 519 | }); 520 | 521 | test('update app runner with source code configuration', async () => { 522 | const inputConfig: FakeInput = { 523 | service: SERVICE_NAME, 524 | "source-connection-arn": SOURCE_ARN_CONNECTION, 525 | repo: REPO, 526 | runtime: RUNTIME, 527 | "build-command": BUILD_COMMAND, 528 | "start-command": START_COMMAND, 529 | port: PORT, 530 | "wait-for-service-stability": 'false', 531 | tags: TAGS, 532 | 'auto-scaling-config-arn': AUTO_SCALING_CONFIG_ARN, 533 | }; 534 | 535 | getInputMock.mockImplementation((name) => { 536 | return getFakeInput(inputConfig, name); 537 | }); 538 | getMultilineInputMock.mockImplementation((name) => { 539 | return getFakeMultilineInput({} as FakeMultilineInput, name); 540 | }); 541 | 542 | mockSendDef.mockImplementationOnce(async (cmd: ListServicesCommand) => { 543 | expect(cmd.input.NextToken).toBeUndefined(); 544 | return ({ 545 | NextToken: undefined, 546 | ServiceSummaryList: [{ 547 | ServiceName: SERVICE_NAME, 548 | ServiceArn: SERVICE_ARN, 549 | Status: ServiceStatus.RUNNING, 550 | }] 551 | }); 552 | }); 553 | mockSendDef.mockImplementationOnce(async (cmd: TagResourceCommand) => { 554 | expect(cmd.input).toMatchObject({ 555 | ResourceArn: SERVICE_ARN, // tag resource command requires service arn 556 | Tags: [{ Key: 'env', Value: 'test' }], 557 | }) 558 | return 559 | }) 560 | mockSendDef.mockImplementationOnce(async (cmd: UpdateServiceCommand) => { 561 | expect(cmd.input).toMatchObject({ 562 | ServiceArn: SERVICE_ARN, // update command requires service arn 563 | InstanceConfiguration: { 564 | Cpu: `1 vCPU`, 565 | Memory: `2 GB`, 566 | }, 567 | AutoScalingConfigurationArn: AUTO_SCALING_CONFIG_ARN, 568 | SourceConfiguration: { 569 | AuthenticationConfiguration: { 570 | ConnectionArn: SOURCE_ARN_CONNECTION, 571 | }, 572 | AutoDeploymentsEnabled: true, 573 | CodeRepository: { 574 | RepositoryUrl: REPO, 575 | SourceCodeVersion: { 576 | Type: 'BRANCH', 577 | Value: 'main', 578 | }, 579 | CodeConfiguration: { 580 | ConfigurationSource: 'API', 581 | CodeConfigurationValues: { 582 | Runtime: RUNTIME, 583 | BuildCommand: BUILD_COMMAND, 584 | StartCommand: START_COMMAND, 585 | Port: PORT, 586 | RuntimeEnvironmentVariables: undefined, 587 | RuntimeEnvironmentSecrets: undefined, 588 | }, 589 | }, 590 | }, 591 | }, 592 | }); 593 | return ({ Service: { ServiceId: SERVICE_ID, ServiceArn: SERVICE_ARN, ServiceUrl: SERVICE_URL }, OperationId: OPERATION_ID }); 594 | }); 595 | 596 | await run(); 597 | 598 | expect(setFailedMock).not.toHaveBeenCalled(); 599 | expect(appRunnerClientMock.mock.calls).toHaveLength(1); 600 | expect(appRunnerClientMock.mock.calls[0][0]).toMatchObject({ region: DEFAULT_REGION }); 601 | expect(setOutputMock).toHaveBeenNthCalledWith(1, 'service-id', SERVICE_ID); 602 | expect(setOutputMock).toHaveBeenNthCalledWith(2, 'service-arn', SERVICE_ARN); 603 | expect(setOutputMock).toHaveBeenNthCalledWith(3, 'service-url', SERVICE_URL); 604 | expect(infoMock).toBeCalledWith(`Service ${SERVICE_ID} has started an update. Watch for its progress in the AppRunner console`); 605 | }); 606 | 607 | test('register app runner using docker registry configuration', async () => { 608 | const inputConfig: FakeInput = { 609 | service: SERVICE_NAME, 610 | "access-role-arn": ACCESS_ROLE_ARN, 611 | image: PUBLIC_DOCKER_IMAGE, 612 | }; 613 | 614 | getInputMock.mockImplementation((name) => { 615 | return getFakeInput(inputConfig, name); 616 | }); 617 | getMultilineInputMock.mockImplementation((name) => { 618 | return getFakeMultilineInput({} as FakeMultilineInput, name); 619 | }); 620 | 621 | mockSendDef.mockImplementationOnce(async (cmd: ListServicesCommand) => { 622 | expect(cmd.input.NextToken).toBeUndefined(); 623 | return ({ NextToken: undefined, ServiceSummaryList: [] }); 624 | }); 625 | mockSendDef.mockImplementationOnce(async (cmd: CreateServiceCommand) => { 626 | expect(cmd.input).toMatchObject({ 627 | ServiceName: SERVICE_NAME, // create command requires service name 628 | InstanceConfiguration: { 629 | Cpu: `1 vCPU`, 630 | Memory: `2 GB`, 631 | }, 632 | SourceConfiguration: { 633 | AuthenticationConfiguration: { 634 | AccessRoleArn: ACCESS_ROLE_ARN, 635 | }, 636 | ImageRepository: { 637 | ImageIdentifier: PUBLIC_DOCKER_IMAGE, 638 | ImageRepositoryType: ImageRepositoryType.ECR_PUBLIC, 639 | ImageConfiguration: { 640 | Port: PORT, 641 | RuntimeEnvironmentVariables: undefined, 642 | RuntimeEnvironmentSecrets: undefined, 643 | }, 644 | }, 645 | }, 646 | }); 647 | return ({ Service: { ServiceId: SERVICE_ID, ServiceArn: SERVICE_ARN, ServiceUrl: SERVICE_URL } }); 648 | }); 649 | 650 | await run(); 651 | 652 | expect(setFailedMock).not.toHaveBeenCalled(); 653 | expect(appRunnerClientMock.mock.calls).toHaveLength(1); 654 | expect(appRunnerClientMock.mock.calls[0][0]).toMatchObject({ region: DEFAULT_REGION }); 655 | expect(setOutputMock).toHaveBeenNthCalledWith(1, 'service-id', SERVICE_ID); 656 | expect(setOutputMock).toHaveBeenNthCalledWith(2, 'service-arn', SERVICE_ARN); 657 | expect(setOutputMock).toHaveBeenNthCalledWith(3, 'service-url', SERVICE_URL); 658 | expect(infoMock).toBeCalledWith(`Service ${SERVICE_ID} has started an update. Watch for its progress in the AppRunner console`); 659 | }); 660 | 661 | test('register app runner using private docker registry configuration', async () => { 662 | const inputConfig: FakeInput = { 663 | service: SERVICE_NAME, 664 | "access-role-arn": ACCESS_ROLE_ARN, 665 | image: '811284229777.dkr.ecr.us-east-1.amazonaws.com/blazingtext:1', 666 | }; 667 | 668 | getInputMock.mockImplementation((name) => { 669 | return getFakeInput(inputConfig, name); 670 | }); 671 | getMultilineInputMock.mockImplementation((name) => { 672 | return getFakeMultilineInput({} as FakeMultilineInput, name); 673 | }); 674 | 675 | mockSendDef.mockImplementationOnce(async (cmd: ListServicesCommand) => { 676 | expect(cmd.input.NextToken).toBeUndefined(); 677 | return ({ NextToken: undefined, ServiceSummaryList: [] }); 678 | }); 679 | mockSendDef.mockImplementationOnce(async (cmd: CreateServiceCommand) => { 680 | expect(cmd.input).toMatchObject({ 681 | ServiceName: SERVICE_NAME, // create command requires service name 682 | InstanceConfiguration: { 683 | Cpu: `1 vCPU`, 684 | Memory: `2 GB`, 685 | }, 686 | SourceConfiguration: { 687 | AuthenticationConfiguration: { 688 | AccessRoleArn: ACCESS_ROLE_ARN, 689 | }, 690 | ImageRepository: { 691 | ImageIdentifier: '811284229777.dkr.ecr.us-east-1.amazonaws.com/blazingtext:1', 692 | ImageRepositoryType: ImageRepositoryType.ECR, 693 | ImageConfiguration: { 694 | Port: PORT, 695 | RuntimeEnvironmentVariables: undefined, 696 | RuntimeEnvironmentSecrets: undefined, 697 | }, 698 | }, 699 | }, 700 | }); 701 | return ({ Service: { ServiceId: SERVICE_ID, ServiceArn: SERVICE_ARN, ServiceUrl: SERVICE_URL } }); 702 | }); 703 | 704 | await run(); 705 | 706 | expect(setFailedMock).not.toHaveBeenCalled(); 707 | expect(appRunnerClientMock.mock.calls).toHaveLength(1); 708 | expect(appRunnerClientMock.mock.calls[0][0]).toMatchObject({ region: DEFAULT_REGION }); 709 | expect(setOutputMock).toHaveBeenNthCalledWith(1, 'service-id', SERVICE_ID); 710 | expect(setOutputMock).toHaveBeenNthCalledWith(2, 'service-arn', SERVICE_ARN); 711 | expect(setOutputMock).toHaveBeenNthCalledWith(3, 'service-url', SERVICE_URL); 712 | expect(infoMock).toBeCalledWith(`Service ${SERVICE_ID} has started an update. Watch for its progress in the AppRunner console`); 713 | }); 714 | 715 | test('update app runner using docker registry configuration', async () => { 716 | const inputConfig: FakeInput = { 717 | service: SERVICE_NAME, 718 | "access-role-arn": ACCESS_ROLE_ARN, 719 | "instance-role-arn": INSTANCE_ROLE_ARN, 720 | image: PUBLIC_DOCKER_IMAGE, 721 | }; 722 | const multiLineInputConfig = { 723 | 'copy-env-vars': ['_NON_EXISTENT_VAR_'], 724 | 'copy-secret-env-vars': ['_NON_EXISTENT_SECRET_VAR_'] 725 | } 726 | 727 | getInputMock.mockImplementation((name) => { 728 | return getFakeInput(inputConfig, name); 729 | }); 730 | getMultilineInputMock.mockImplementation((name) => { 731 | return getFakeMultilineInput(multiLineInputConfig, name); 732 | }); 733 | 734 | mockSendDef.mockImplementationOnce(async (cmd: ListServicesCommand) => { 735 | expect(cmd.input.NextToken).toBeUndefined(); 736 | return ({ 737 | NextToken: undefined, 738 | ServiceSummaryList: [{ 739 | ServiceName: SERVICE_NAME, 740 | ServiceArn: SERVICE_ARN, 741 | Status: ServiceStatus.RUNNING, 742 | }] 743 | }); 744 | }); 745 | mockSendDef.mockImplementationOnce(async (cmd: UpdateServiceCommand) => { 746 | expect(cmd.input).toMatchObject({ 747 | ServiceArn: SERVICE_ARN, // update command requires service arn 748 | InstanceConfiguration: { 749 | Cpu: `1 vCPU`, 750 | Memory: `2 GB`, 751 | InstanceRoleArn: INSTANCE_ROLE_ARN, 752 | }, 753 | SourceConfiguration: { 754 | AuthenticationConfiguration: { 755 | AccessRoleArn: ACCESS_ROLE_ARN, 756 | }, 757 | ImageRepository: { 758 | ImageIdentifier: PUBLIC_DOCKER_IMAGE, 759 | ImageRepositoryType: ImageRepositoryType.ECR_PUBLIC, 760 | ImageConfiguration: { 761 | Port: PORT, 762 | RuntimeEnvironmentVariables: undefined, 763 | RuntimeEnvironmentSecrets: undefined, 764 | }, 765 | }, 766 | }, 767 | }); 768 | return ({ Service: { ServiceId: SERVICE_ID, ServiceArn: SERVICE_ARN, ServiceUrl: SERVICE_URL }, OperationId: OPERATION_ID }); 769 | }); 770 | 771 | await run(); 772 | 773 | expect(setFailedMock).not.toHaveBeenCalled(); 774 | expect(appRunnerClientMock.mock.calls).toHaveLength(1); 775 | expect(appRunnerClientMock.mock.calls[0][0]).toMatchObject({ region: DEFAULT_REGION }); 776 | expect(setOutputMock).toHaveBeenNthCalledWith(1, 'service-id', SERVICE_ID); 777 | expect(setOutputMock).toHaveBeenNthCalledWith(2, 'service-arn', SERVICE_ARN); 778 | expect(setOutputMock).toHaveBeenNthCalledWith(3, 'service-url', SERVICE_URL); 779 | expect(infoMock).toBeCalledWith(`Service ${SERVICE_ID} has started an update. Watch for its progress in the AppRunner console`); 780 | }); 781 | 782 | test('update app runner with pagination', async () => { 783 | const inputConfig: FakeInput = { 784 | service: SERVICE_NAME, 785 | "access-role-arn": ACCESS_ROLE_ARN, 786 | image: PUBLIC_DOCKER_IMAGE, 787 | }; 788 | 789 | getInputMock.mockImplementation((name) => { 790 | return getFakeInput(inputConfig, name); 791 | }); 792 | getMultilineInputMock.mockImplementation((name) => { 793 | return getFakeMultilineInput({} as FakeMultilineInput, name); 794 | }); 795 | 796 | const nextToken = 'next-token'; 797 | mockSendDef.mockImplementationOnce(async (cmd: ListServicesCommand) => { 798 | expect(cmd.input.NextToken).toBeUndefined(); 799 | return ({ 800 | NextToken: nextToken, 801 | }); 802 | }); 803 | mockSendDef.mockImplementationOnce(async (cmd: ListServicesCommand) => { 804 | expect(cmd.input.NextToken).toEqual(nextToken); 805 | return ({ 806 | NextToken: undefined, 807 | ServiceSummaryList: [{ 808 | ServiceName: SERVICE_NAME, 809 | ServiceArn: SERVICE_ARN, 810 | Status: ServiceStatus.RUNNING, 811 | }] 812 | }); 813 | }); 814 | mockSendDef.mockImplementationOnce(async (cmd: UpdateServiceCommand) => { 815 | expect(cmd.input).toMatchObject({ 816 | ServiceArn: SERVICE_ARN, // update command requires service arn 817 | InstanceConfiguration: { 818 | Cpu: `1 vCPU`, 819 | Memory: `2 GB`, 820 | }, 821 | SourceConfiguration: { 822 | AuthenticationConfiguration: { 823 | AccessRoleArn: ACCESS_ROLE_ARN, 824 | }, 825 | ImageRepository: { 826 | ImageIdentifier: PUBLIC_DOCKER_IMAGE, 827 | ImageRepositoryType: ImageRepositoryType.ECR_PUBLIC, 828 | ImageConfiguration: { 829 | Port: PORT, 830 | RuntimeEnvironmentVariables: undefined, 831 | RuntimeEnvironmentSecrets: undefined, 832 | }, 833 | }, 834 | }, 835 | }); 836 | return ({ Service: { ServiceId: SERVICE_ID, ServiceArn: SERVICE_ARN, ServiceUrl: SERVICE_URL }, OperationId: OPERATION_ID }); 837 | }); 838 | 839 | await run(); 840 | 841 | expect(setFailedMock).not.toHaveBeenCalled(); 842 | expect(appRunnerClientMock.mock.calls).toHaveLength(1); 843 | expect(appRunnerClientMock.mock.calls[0][0]).toMatchObject({ region: DEFAULT_REGION }); 844 | expect(setOutputMock).toHaveBeenNthCalledWith(1, 'service-id', SERVICE_ID); 845 | expect(setOutputMock).toHaveBeenNthCalledWith(2, 'service-arn', SERVICE_ARN); 846 | expect(setOutputMock).toHaveBeenNthCalledWith(3, 'service-url', SERVICE_URL); 847 | expect(infoMock).toBeCalledWith(`Service ${SERVICE_ID} has started an update. Watch for its progress in the AppRunner console`); 848 | }); 849 | 850 | test('update app runner with service rollback', async () => { 851 | const inputConfig: FakeInput = { 852 | service: SERVICE_NAME, 853 | "access-role-arn": ACCESS_ROLE_ARN, 854 | image: PUBLIC_DOCKER_IMAGE, 855 | "wait-for-service-stability": 'true', 856 | }; 857 | const multiLineInputConfig = { 858 | 'copy-env-vars': ['_NON_EXISTENT_VAR_'], 859 | 'copy-secret-env-vars': ['_NON_EXISTENT_SECRET_VAR_'] 860 | } 861 | 862 | getInputMock.mockImplementation((name) => { 863 | return getFakeInput(inputConfig, name); 864 | }); 865 | getMultilineInputMock.mockImplementation((name) => { 866 | return getFakeMultilineInput(multiLineInputConfig, name); 867 | }); 868 | 869 | mockSendDef.mockImplementationOnce(async (cmd: ListServicesCommand) => { 870 | expect(cmd.input.NextToken).toBeUndefined(); 871 | return ({ 872 | NextToken: undefined, 873 | ServiceSummaryList: [{ 874 | ServiceName: SERVICE_NAME, 875 | ServiceArn: SERVICE_ARN, 876 | Status: ServiceStatus.RUNNING, 877 | }] 878 | }); 879 | }); 880 | mockSendDef.mockImplementationOnce(async (cmd: UpdateServiceCommand) => { 881 | expect(cmd.input).toMatchObject({ 882 | ServiceArn: SERVICE_ARN, // update command requires service arn 883 | InstanceConfiguration: { 884 | Cpu: `1 vCPU`, 885 | Memory: `2 GB`, 886 | }, 887 | SourceConfiguration: { 888 | AuthenticationConfiguration: { 889 | AccessRoleArn: ACCESS_ROLE_ARN, 890 | }, 891 | ImageRepository: { 892 | ImageIdentifier: PUBLIC_DOCKER_IMAGE, 893 | ImageRepositoryType: ImageRepositoryType.ECR_PUBLIC, 894 | ImageConfiguration: { 895 | Port: PORT, 896 | RuntimeEnvironmentVariables: undefined, 897 | RuntimeEnvironmentSecrets: undefined, 898 | }, 899 | }, 900 | }, 901 | }); 902 | return ({ Service: { ServiceId: SERVICE_ID, ServiceArn: SERVICE_ARN, ServiceUrl: SERVICE_URL }, OperationId: OPERATION_ID }); 903 | }); 904 | mockSendDef.mockImplementationOnce(async (cmd: DescribeServiceCommand) => { 905 | expect(cmd.input).toMatchObject({ 906 | ServiceArn: SERVICE_ARN, 907 | }); 908 | return { Service: { Status: ServiceStatus.RUNNING } }; 909 | }); 910 | mockSendDef.mockImplementationOnce(async (cmd: ListOperationsCommand) => { 911 | expect(cmd.input).toMatchObject({ 912 | ServiceArn: SERVICE_ARN, 913 | }) 914 | return ({ OperationSummaryList: [ { Id: OPERATION_ID, Status: OperationStatus.FAILED }] }) 915 | }); 916 | 917 | await run(); 918 | 919 | expect(setFailedMock).toBeCalledWith(`Operation ${OPERATION_ID} is not successful. Its current status is ${OperationStatus.FAILED}`) 920 | expect(appRunnerClientMock.mock.calls).toHaveLength(1); 921 | expect(appRunnerClientMock.mock.calls[0][0]).toMatchObject({ region: DEFAULT_REGION }); 922 | }) 923 | 924 | test('existing CREATE_FAILED service is deleted first', async () => { 925 | const inputConfig: FakeInput = { 926 | service: SERVICE_NAME, 927 | "access-role-arn": ACCESS_ROLE_ARN, 928 | image: PUBLIC_DOCKER_IMAGE, 929 | }; 930 | const multiLineInputConfig = { 931 | 'copy-env-vars': ['_NON_EXISTENT_VAR_'], 932 | 'copy-secret-env-vars': ['_NON_EXISTENT_SECRET_VAR_'], 933 | } 934 | 935 | getInputMock.mockImplementation((name) => { 936 | return getFakeInput(inputConfig, name); 937 | }); 938 | getMultilineInputMock.mockImplementation((name) => { 939 | return getFakeMultilineInput(multiLineInputConfig, name); 940 | }); 941 | 942 | mockSendDef.mockImplementationOnce(async (cmd: ListServicesCommand) => { 943 | expect(cmd.input.NextToken).toBeUndefined(); 944 | return ({ 945 | NextToken: undefined, 946 | ServiceSummaryList: [{ 947 | ServiceName: SERVICE_NAME, 948 | ServiceArn: SERVICE_ARN, 949 | Status: ServiceStatus.CREATE_FAILED, 950 | }] 951 | }); 952 | }); 953 | mockSendDef.mockImplementationOnce(async (cmd: DeleteServiceCommand) => { 954 | expect(cmd.input).toMatchObject({ 955 | ServiceArn: SERVICE_ARN, 956 | }); 957 | return ({}); 958 | }); 959 | mockSendDef.mockImplementationOnce(async (cmd: DescribeServiceCommand) => { 960 | expect(cmd.input).toMatchObject({ 961 | ServiceArn: SERVICE_ARN, 962 | }); 963 | return { Service: { Status: ServiceStatus.OPERATION_IN_PROGRESS } }; // simulate delay 964 | }); 965 | mockSendDef.mockImplementationOnce(async (cmd: DescribeServiceCommand) => { 966 | expect(cmd.input).toMatchObject({ 967 | ServiceArn: SERVICE_ARN, 968 | }); 969 | return { Service: { Status: ServiceStatus.DELETED } }; 970 | }); 971 | mockSendDef.mockImplementationOnce(async (cmd: CreateServiceCommand) => { 972 | expect(cmd.input).toMatchObject({ 973 | ServiceName: SERVICE_NAME, // create command requires service name 974 | InstanceConfiguration: { 975 | Cpu: `1 vCPU`, 976 | Memory: `2 GB`, 977 | }, 978 | SourceConfiguration: { 979 | AuthenticationConfiguration: { 980 | AccessRoleArn: ACCESS_ROLE_ARN, 981 | }, 982 | ImageRepository: { 983 | ImageIdentifier: PUBLIC_DOCKER_IMAGE, 984 | ImageRepositoryType: ImageRepositoryType.ECR_PUBLIC, 985 | ImageConfiguration: { 986 | Port: PORT, 987 | RuntimeEnvironmentVariables: undefined, 988 | RuntimeEnvironmentSecrets: undefined, 989 | }, 990 | }, 991 | }, 992 | }); 993 | return ({ Service: { ServiceId: SERVICE_ID, ServiceArn: SERVICE_ARN, ServiceUrl: SERVICE_URL } }); 994 | }); 995 | 996 | await run(); 997 | 998 | expect(setFailedMock).not.toHaveBeenCalled(); 999 | expect(appRunnerClientMock.mock.calls).toHaveLength(1); 1000 | expect(appRunnerClientMock.mock.calls[0][0]).toMatchObject({ region: DEFAULT_REGION }); 1001 | expect(setOutputMock).toHaveBeenNthCalledWith(1, 'service-id', SERVICE_ID); 1002 | expect(setOutputMock).toHaveBeenNthCalledWith(2, 'service-arn', SERVICE_ARN); 1003 | expect(setOutputMock).toHaveBeenNthCalledWith(3, 'service-url', SERVICE_URL); 1004 | expect(infoMock).toBeCalledWith(`Service ${SERVICE_ID} has started an update. Watch for its progress in the AppRunner console`); 1005 | }); 1006 | }); 1007 | --------------------------------------------------------------------------------