├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yaml │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yaml └── workflows │ └── ci.yaml ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── assets └── test.html ├── bin └── finch-aws-cdk.ts ├── cdk.context.json ├── cdk.json ├── config ├── env-config.json ├── env-config.ts ├── runner-config.json └── runner-config.ts ├── integration_test └── cloudfront_service.test.ts ├── jest.config.integration.js ├── jest.config.js ├── jest.config.unit.js ├── lib ├── artifact-bucket-cloudfront.ts ├── asg-runner-stack.ts ├── aspects │ └── stack-termination-protection.ts ├── cloudfront_cdn.ts ├── codebuild-stack.ts ├── continuous-integration-stack.ts ├── ecr-repo-stack.ts ├── event-bridge-scan-notifs-stack.ts ├── finch-pipeline-app-stage.ts ├── finch-pipeline-stack.ts ├── image-scanning-notifications-lambda-handler │ └── main.py ├── pvre-reporting-stack.ts ├── pvre-reporting-template.yml ├── ssm-patching-stack.ts └── utils.ts ├── package-lock.json ├── package.json ├── scripts ├── setup-linux-runner.sh ├── setup-runner.sh └── windows-runner-user-data.yaml ├── test ├── artifact-bucket-cloudfront.test.ts ├── asg-runner-stack.test.ts ├── aspects │ └── stack-termination-protection.test.ts ├── cloudfront_cdn.test.ts ├── codebuild-stack.test.ts ├── ecr-repo-stack.test.ts ├── event-bridge-scan-notifs-stack.test.ts ├── finch-pipeline-stack.test.ts ├── ssm-patching-stack.test.ts └── utils.test.ts └── tsconfig.json /.github/ ISSUE_TEMPLATE /bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug to help improve the infrastructure of Finch 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | Briefly describe the probem you are having. 12 | 13 | 14 | **Steps to reproduce** 15 | A clear, step-by-step set of instructions to reproduce the bug. 16 | 17 | 18 | **Expected behavior** 19 | Description of what you expected to happen. 20 | 21 | 22 | **Screenshots or logs** 23 | If applicable, add screenshots or logs to help explain your problem. 24 | 25 | 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/ ISSUE_TEMPLATE /config.yaml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Ask a question 4 | url: https://github.com/runfinch/infrastructure/discussions 5 | about: Use GitHub Discussions to ask questions, discuss options, or propose new ideas -------------------------------------------------------------------------------- /.github/ ISSUE_TEMPLATE /feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest a new feature to add to the infrastructure of Finch 4 | title: '' 5 | labels: 'feature' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What is the problem you're trying to solve?** 11 | A clear and concise description of the use case for this feature. Please provide an example, if possible. 12 | 13 | 14 | **Describe the feature you'd like** 15 | A clear and concise description of what you'd like to happen. 16 | 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | 4 | *Description of changes:* 5 | 6 | 7 | *Testing done:* 8 | 9 | 10 | 11 | - [ ] I've reviewed the guidance in CONTRIBUTING.md 12 | 13 | 14 | #### License Acceptance 15 | 16 | By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. 17 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | commit-message: 8 | prefix: "build" 9 | include: "scope" 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: "daily" 14 | commit-message: 15 | prefix: "ci" 16 | include: "scope" 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths-ignore: 7 | - '*.md' 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | unit-tests: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 16 20 | - run: npm ci 21 | - run: npm run build 22 | - run: npm run test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | !jest.config.integration.js 4 | !jest.config.unit.js 5 | *.d.ts 6 | node_modules 7 | dist 8 | 9 | # CDK asset staging directory 10 | .cdk.staging 11 | cdk.out 12 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 120 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | 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 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS CDK repo for Finch 2 | 3 | ## Prerequisites 4 | 5 | - Install [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) and [configured to the right account and user](https://cdkworkshop.com/15-prerequisites/200-account.html) 6 | - Install Node.js (>= 10.13.0, except for versions 13.0.0 - 13.6.0) 7 | - Install AWS CDK Toolkit with `npm install -g aws-cdk`
8 | 9 | ## Deployment Steps 10 | 11 | **_Step 1:_** Clone the infrastructure repo and open a terminal.
12 | **_Step 2:_** Before the deployment, check whether the key pair `runner-key.pem` exists in your AWS EC2 console. If not, set up a ssh key pair `runner-key.pem` for ssh to the ec2 instance. Go to AWS console > EC2, in the left tab “Network & Security” > “Key Pairs”, click “Create key pair” with name runner-key.pem.
13 | Or create the key pair with the AWS CLI command below.
14 | `aws ec2 create-key-pair --key-name runner-key --output text > runner-key.pem` 15 | 16 | **_Step 3:_** For the first time deployment to an environment, run `cdk bootstrap aws://PIPELINE-ACCOUNT-NUMBER/REGION` to bootstrap the pipeline account and `cdk bootstrap --cloudformation-execution-policies 'arn:aws:iam::aws:policy/AdministratorAccess' --trust PIPELINE-ACCOUNT-NUMBER aws://STAGE-ACCOUNT-NUMBER/REGION` to bootstrap the beta/prod accounts. Then run `cdk deploy` with the pipeline account credentials set up to deploy the pipeline stack, and all the application stacks will be deployed by the pipeline for each commit.
17 | 18 | ### Self-hosted Runners (Mac Arm64 and Mac Amd64) 19 | 20 | The stack `ASGRunnerStack` is used to provision EC2 Mac instances through an autoscaling group. The runner configurations can be edited in `config/runner-config.json`.
21 | When the runners are initialized, a user data script (`scripts/setup-runner.sh`) runs to setup the instance. This script downloads and installs [GitHub actions runner application](https://github.com/actions/runner) on the our self-hosted runner, which is used to connect our runner with the GitHub actions. Then the script connects the instance with our GitHub repos, starting the runner service.
22 | 23 | #### Connect to the runners for troubleshooting 24 | 25 | After the runner is linked to the GitHub repo, you can access a runner to trouble shoot it by noting down the name of the runner (usually `ip172-31-xx-xxx`), access the AWS account that hosts the instance, and find the instance with the private IP address matching the name above. Connect to the runner either by using an SSH service with the key saved in the Secret Manager, or use Session Manager in EC2. Go to the AWS EC2 console, select the instance and click Connect > Session Manager.
26 | 27 | ### S3 Bucket and Cloudfront Distributions 28 | 29 | The S3 buckets are used for storing project artifacts and dependencies that should be publicly accessible. To make the content delivery more effective and secure, we also set up CloudFront to work with the S3 buckets.
30 | The construct `CloudfrontCdn` creates a new CloudFront distribution in front of an existing S3 bucket and adds an OAI to it which makes the content in the bucket can be read by the CloudFront distribution. Users can then access the bucket objects through the CloudFront domain instead of the S3 bucket URL, and benefit from CloudFront's features, like caching.
31 | 32 | - Get the distribution domain from the AWS console. 33 | - Enter the CloudFront URL, concatenated with the path to a file in your browser to download a file. 34 | For example, `*.cloudfront.net/path/to/file`. 35 | 36 | ## Unit and Integration Tests 37 | 38 | The unit tests and integration tests are both executed by the pipeline post-deployment steps in `Beta` stage. Or you can run the tests with the command below.
39 | 40 | ``` 41 | npm run test 42 | npm run integration 43 | ``` 44 | 45 | Format your code with the command `npm run prettier-format`.
46 | 47 | ## Host Licensing 48 | 49 | When using Auto Scaling Group with macOS instances on EC2, a host license has to be created in AWS License Manager. Create a self-managed license named `MacHostLicense`, set the license type to `sockets`, and save the arn to the `runner-config.json` file. 50 | 51 | 52 | ## Access Tokens 53 | 54 | Overall, 3 access tokens are required - one for the pipeline to access the runfinch/infrastructure code and update when there is a code update, and two for runfinch/finch and runfinch/finch-core to provide the github runner keys for automatically creating and registering the runners -------------------------------------------------------------------------------- /assets/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hello world! 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |

Hello world!

13 | 14 | 15 | -------------------------------------------------------------------------------- /bin/finch-aws-cdk.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import * as cdk from 'aws-cdk-lib'; 4 | import { FinchPipelineStack } from '../lib/finch-pipeline-stack'; 5 | import { EnvConfig } from '../config/env-config'; 6 | 7 | const app = new cdk.App(); 8 | new FinchPipelineStack(app, 'FinchPipelineStack', { env: EnvConfig.envPipeline, terminationProtection: true }); 9 | 10 | app.synth(); 11 | -------------------------------------------------------------------------------- /cdk.context.json: -------------------------------------------------------------------------------- 1 | { 2 | "availability-zones:account=820462304213:region=us-west-2": [ 3 | "us-west-2a", 4 | "us-west-2b", 5 | "us-west-2c", 6 | "us-west-2d" 7 | ], 8 | "availability-zones:account=090529234398:region=us-west-2": [ 9 | "us-west-2a", 10 | "us-west-2b", 11 | "us-west-2c", 12 | "us-west-2d" 13 | ], 14 | "vpc-provider:account=820462304213:filter.isDefault=true:region=us-west-2:returnAsymmetricSubnets=true": { 15 | "vpcId": "vpc-06de4cdd1050128af", 16 | "vpcCidrBlock": "172.31.0.0/16", 17 | "availabilityZones": [], 18 | "subnetGroups": [ 19 | { 20 | "name": "Public", 21 | "type": "Public", 22 | "subnets": [ 23 | { 24 | "subnetId": "subnet-0ebe88c6840a07821", 25 | "cidr": "172.31.16.0/20", 26 | "availabilityZone": "us-west-2a", 27 | "routeTableId": "rtb-0dc49c558df03064c" 28 | }, 29 | { 30 | "subnetId": "subnet-0babc2053bb07dabc", 31 | "cidr": "172.31.32.0/20", 32 | "availabilityZone": "us-west-2b", 33 | "routeTableId": "rtb-0dc49c558df03064c" 34 | }, 35 | { 36 | "subnetId": "subnet-0b5c52262bc868779", 37 | "cidr": "172.31.0.0/20", 38 | "availabilityZone": "us-west-2c", 39 | "routeTableId": "rtb-0dc49c558df03064c" 40 | }, 41 | { 42 | "subnetId": "subnet-0d19a1a00efb1f4a3", 43 | "cidr": "172.31.48.0/20", 44 | "availabilityZone": "us-west-2d", 45 | "routeTableId": "rtb-0dc49c558df03064c" 46 | } 47 | ] 48 | } 49 | ] 50 | }, 51 | "vpc-provider:account=090529234398:filter.isDefault=true:region=us-west-2:returnAsymmetricSubnets=true": { 52 | "vpcId": "vpc-04f2a33e813718bda", 53 | "vpcCidrBlock": "172.31.0.0/16", 54 | "availabilityZones": [], 55 | "subnetGroups": [ 56 | { 57 | "name": "Public", 58 | "type": "Public", 59 | "subnets": [ 60 | { 61 | "subnetId": "subnet-085ebdeb2a40fb8d8", 62 | "cidr": "172.31.32.0/20", 63 | "availabilityZone": "us-west-2a", 64 | "routeTableId": "rtb-038f353b4f9099666" 65 | }, 66 | { 67 | "subnetId": "subnet-02caced18cce39965", 68 | "cidr": "172.31.16.0/20", 69 | "availabilityZone": "us-west-2b", 70 | "routeTableId": "rtb-038f353b4f9099666" 71 | }, 72 | { 73 | "subnetId": "subnet-016fe97f7e20db500", 74 | "cidr": "172.31.0.0/20", 75 | "availabilityZone": "us-west-2c", 76 | "routeTableId": "rtb-038f353b4f9099666" 77 | }, 78 | { 79 | "subnetId": "subnet-09f074e9d6c16c1f8", 80 | "cidr": "172.31.48.0/20", 81 | "availabilityZone": "us-west-2d", 82 | "routeTableId": "rtb-038f353b4f9099666" 83 | } 84 | ] 85 | } 86 | ] 87 | }, 88 | "vpc-provider:account=019528120233:filter.isDefault=true:region=us-east-2:returnAsymmetricSubnets=true": { 89 | "vpcId": "vpc-09e086b046b77b394", 90 | "vpcCidrBlock": "172.31.0.0/16", 91 | "availabilityZones": [], 92 | "subnetGroups": [ 93 | { 94 | "name": "Public", 95 | "type": "Public", 96 | "subnets": [ 97 | { 98 | "subnetId": "subnet-0ed000ae57a777892", 99 | "cidr": "172.31.0.0/20", 100 | "availabilityZone": "us-east-2a", 101 | "routeTableId": "rtb-03950063f7712710d" 102 | }, 103 | { 104 | "subnetId": "subnet-01561873c2253c130", 105 | "cidr": "172.31.16.0/20", 106 | "availabilityZone": "us-east-2b", 107 | "routeTableId": "rtb-03950063f7712710d" 108 | }, 109 | { 110 | "subnetId": "subnet-0fd01d9d69c3969ff", 111 | "cidr": "172.31.32.0/20", 112 | "availabilityZone": "us-east-2c", 113 | "routeTableId": "rtb-03950063f7712710d" 114 | } 115 | ] 116 | } 117 | ] 118 | }, 119 | "ami:account=090529234398:filters.architecture.0=arm64_mac:filters.image-type.0=machine:filters.name.0=amzn-ec2-macos-14.1*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-west-2": "ami-0a18d0d3ba8c9596b", 120 | "ami:account=090529234398:filters.architecture.0=x86_64_mac:filters.image-type.0=machine:filters.name.0=amzn-ec2-macos-14.1*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-west-2": "ami-0a3536378b0dcbcb0", 121 | "ami:account=019528120233:filters.architecture.0=arm64_mac:filters.image-type.0=machine:filters.name.0=amzn-ec2-macos-14.1*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-east-2": "ami-0fb1a8ce1a0e727d8", 122 | "ami:account=019528120233:filters.architecture.0=x86_64_mac:filters.image-type.0=machine:filters.name.0=amzn-ec2-macos-14.1*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-east-2": "ami-0513d1dba13c208f0", 123 | "ami:account=820462304213:filters.architecture.0=arm64_mac:filters.image-type.0=machine:filters.name.0=amzn-ec2-macos-13.6*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-west-2": "ami-0b71bd84ecf59ddb6", 124 | "ami:account=820462304213:filters.architecture.0=x86_64_mac:filters.image-type.0=machine:filters.name.0=amzn-ec2-macos-13.6*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-west-2": "ami-0bd1dced82749f0c9", 125 | "ami:account=090529234398:filters.architecture.0=arm64_mac:filters.image-type.0=machine:filters.name.0=amzn-ec2-macos-13.6*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-west-2": "ami-0b71bd84ecf59ddb6", 126 | "ami:account=090529234398:filters.architecture.0=x86_64_mac:filters.image-type.0=machine:filters.name.0=amzn-ec2-macos-13.6*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-west-2": "ami-0bd1dced82749f0c9", 127 | "ami:account=090529234398:filters.architecture.0=arm64_mac:filters.image-type.0=machine:filters.name.0=amzn-ec2-macos-14.5*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-west-2": "ami-0ac6cd62ce9ccbae4", 128 | "ami:account=090529234398:filters.architecture.0=x86_64_mac:filters.image-type.0=machine:filters.name.0=amzn-ec2-macos-14.5*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-west-2": "ami-034d441e881b05d91", 129 | "ami:account=019528120233:filters.architecture.0=arm64_mac:filters.image-type.0=machine:filters.name.0=amzn-ec2-macos-13.6*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-east-2": "ami-0ba140ebbe2896eb2", 130 | "ami:account=019528120233:filters.architecture.0=x86_64_mac:filters.image-type.0=machine:filters.name.0=amzn-ec2-macos-13.6*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-east-2": "ami-0603a57be9b8fc392", 131 | "ami:account=019528120233:filters.architecture.0=arm64_mac:filters.image-type.0=machine:filters.name.0=amzn-ec2-macos-14.5*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-east-2": "ami-08068ef2743ad7862", 132 | "ami:account=019528120233:filters.architecture.0=x86_64_mac:filters.image-type.0=machine:filters.name.0=amzn-ec2-macos-14.5*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-east-2": "ami-0b7a317a30ce6d3a0", 133 | "ami:account=019528120233:filters.architecture.0=arm64_mac:filters.image-type.0=machine:filters.name.0=amzn-ec2-macos-12.7*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-east-2": "ami-07614028fded10805", 134 | "ami:account=019528120233:filters.architecture.0=x86_64_mac:filters.image-type.0=machine:filters.name.0=amzn-ec2-macos-12.7*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-east-2": "ami-07a46e0229d50d989", 135 | "ami:account=820462304213:filters.architecture.0=x86_64:filters.image-type.0=machine:filters.name.0=ubuntu/images/hvm-ssd/ubuntu*22.04*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-west-2": "ami-07ceaccc0c1916f5f", 136 | "ami:account=820462304213:filters.architecture.0=arm64:filters.image-type.0=machine:filters.name.0=ubuntu/images/hvm-ssd/ubuntu*22.04*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-west-2": "ami-0d0af2588c3fa1261", 137 | "ami:account=090529234398:filters.architecture.0=x86_64:filters.image-type.0=machine:filters.name.0=ubuntu/images/hvm-ssd/ubuntu*22.04*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-west-2": "ami-07ceaccc0c1916f5f", 138 | "ami:account=090529234398:filters.architecture.0=arm64:filters.image-type.0=machine:filters.name.0=ubuntu/images/hvm-ssd/ubuntu*22.04*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-west-2": "ami-0d0af2588c3fa1261", 139 | "ami:account=019528120233:filters.architecture.0=x86_64:filters.image-type.0=machine:filters.name.0=ubuntu/images/hvm-ssd/ubuntu*22.04*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-east-2": "ami-0f09ef696435ff61a", 140 | "ami:account=019528120233:filters.architecture.0=arm64:filters.image-type.0=machine:filters.name.0=ubuntu/images/hvm-ssd/ubuntu*22.04*:filters.owner-alias.0=amazon:filters.root-device-type.0=ebs:filters.state.0=available:filters.virtualization-type.0=hvm:region=us-east-2": "ami-09e17de8500439c77" 141 | } 142 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/finch-aws-cdk.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 21 | "@aws-cdk/core:stackRelativeExports": true, 22 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 23 | "@aws-cdk/aws-lambda:recognizeVersionProps": true, 24 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 25 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, 26 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 27 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 28 | "@aws-cdk/core:checkSecretUsage": true, 29 | "@aws-cdk/aws-iam:minimizePolicies": true, 30 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 31 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 32 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 33 | "@aws-cdk/core:target-partitions": [ 34 | "aws", 35 | "aws-cn" 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /config/env-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "envPipeline": {"account": "229528898709", "region":"us-west-2"}, 3 | "envBeta": {"account": "820462304213", "region":"us-west-2"}, 4 | "envProd": {"account": "090529234398", "region":"us-west-2"}, 5 | "envRelease": {"account": "019528120233", "region": "us-east-2"} 6 | } -------------------------------------------------------------------------------- /config/env-config.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import config from './env-config.json'; 3 | 4 | /** 5 | * Class for environment configurations. Outlines the account and region. 6 | */ 7 | class EnvConfigClass { 8 | public readonly envPipeline: cdk.Environment; 9 | public readonly envBeta: cdk.Environment; 10 | public readonly envProd: cdk.Environment; 11 | public readonly envRelease: cdk.Environment; 12 | 13 | constructor(configFile: any) { 14 | if (!configFile.envPipeline) { 15 | throw new Error('Error: envPipeline must be specified.'); 16 | } 17 | this.envPipeline = configFile.envPipeline; 18 | 19 | if (!configFile.envBeta) { 20 | throw new Error('Error: envBeta must be specified.'); 21 | } 22 | this.envBeta = configFile.envBeta; 23 | 24 | if (!configFile.envProd) { 25 | throw new Error('Error: envProd must be specified.'); 26 | } 27 | this.envProd = configFile.envProd; 28 | 29 | if (!configFile.envRelease) { 30 | throw new Error('Error: envRelease must be specified.'); 31 | } 32 | this.envRelease = configFile.envRelease; 33 | } 34 | } 35 | 36 | export const EnvConfig = new EnvConfigClass(config); 37 | -------------------------------------------------------------------------------- /config/runner-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "runnerBeta": { 3 | "macLicenseArn": "arn:aws:license-manager:us-west-2:820462304213:license-configuration:lic-7ac3e249bd13f71b44ac34963a8c3353", 4 | "windowsLicenseArn": "arn:aws:license-manager:us-west-2:820462304213:license-configuration:lic-56c55a611976d66a1357e58077b19e93", 5 | "runnerTypes": [ 6 | { 7 | "platform": "mac", 8 | "version": "13.6", 9 | "arch": "arm", 10 | "repo": "finch", 11 | "desiredInstances": 1, 12 | "availabilityZones": [ 13 | "us-west-2a", 14 | "us-west-2b", 15 | "us-west-2c", 16 | "us-west-2d" 17 | ] 18 | }, 19 | { 20 | "platform": "mac", 21 | "version": "13.6", 22 | "arch": "x86", 23 | "repo": "finch-core", 24 | "desiredInstances": 1, 25 | "availabilityZones": [ 26 | "us-west-2a", 27 | "us-west-2b", 28 | "us-west-2c", 29 | "us-west-2d" 30 | ] 31 | }, 32 | { 33 | "platform": "windows", 34 | "version": "2022", 35 | "arch": "x86", 36 | "repo": "finch", 37 | "desiredInstances": 1, 38 | "availabilityZones": [ 39 | "us-west-2a", 40 | "us-west-2b", 41 | "us-west-2c", 42 | "us-west-2d" 43 | ] 44 | }, 45 | { 46 | "platform": "windows", 47 | "version": "2022", 48 | "arch": "x86", 49 | "repo": "finch-core", 50 | "desiredInstances": 1, 51 | "availabilityZones": [ 52 | "us-west-2a", 53 | "us-west-2b", 54 | "us-west-2c", 55 | "us-west-2d" 56 | ] 57 | }, 58 | { 59 | "platform": "amazonlinux", 60 | "version": "2023", 61 | "arch": "x86", 62 | "repo": "finch", 63 | "desiredInstances": 1, 64 | "availabilityZones": [ 65 | "us-west-2a", 66 | "us-west-2b", 67 | "us-west-2c", 68 | "us-west-2d" 69 | ] 70 | }, 71 | { 72 | "platform": "amazonlinux", 73 | "version": "2", 74 | "arch": "x86", 75 | "repo": "finch", 76 | "desiredInstances": 1, 77 | "availabilityZones": [ 78 | "us-west-2a", 79 | "us-west-2b", 80 | "us-west-2c", 81 | "us-west-2d" 82 | ] 83 | }, 84 | { 85 | "platform": "amazonlinux", 86 | "version": "2023", 87 | "arch": "arm", 88 | "repo": "finch", 89 | "desiredInstances": 1, 90 | "availabilityZones": [ 91 | "us-west-2a", 92 | "us-west-2b", 93 | "us-west-2c", 94 | "us-west-2d" 95 | ] 96 | }, 97 | { 98 | "platform": "amazonlinux", 99 | "version": "2", 100 | "arch": "arm", 101 | "repo": "finch", 102 | "desiredInstances": 1, 103 | "availabilityZones": [ 104 | "us-west-2a", 105 | "us-west-2b", 106 | "us-west-2c", 107 | "us-west-2d" 108 | ] 109 | } 110 | ] 111 | }, 112 | "runnerProd": { 113 | "macLicenseArn": "arn:aws:license-manager:us-west-2:090529234398:license-configuration:lic-c0c3e2458f6d2a45b0bcc8d66fbc072e", 114 | "windowsLicenseArn": "arn:aws:license-manager:us-west-2:090529234398:license-configuration:lic-62c55a61d2d9ce6ef100c6ea267216e6", 115 | "runnerTypes": [ 116 | { 117 | "platform": "mac", 118 | "version": "13.6", 119 | "arch": "arm", 120 | "repo": "finch", 121 | "desiredInstances": 2, 122 | "availabilityZones": [ 123 | "us-west-2a", 124 | "us-west-2b", 125 | "us-west-2c", 126 | "us-west-2d" 127 | ] 128 | }, 129 | { 130 | "platform": "mac", 131 | "version": "13.6", 132 | "arch": "x86", 133 | "repo": "finch", 134 | "desiredInstances": 2, 135 | "availabilityZones": [ 136 | "us-west-2a", 137 | "us-west-2b", 138 | "us-west-2c", 139 | "us-west-2d" 140 | ] 141 | }, 142 | { 143 | "platform": "mac", 144 | "version": "14.5", 145 | "arch": "arm", 146 | "repo": "finch", 147 | "desiredInstances": 2, 148 | "availabilityZones": [ 149 | "us-west-2a", 150 | "us-west-2b", 151 | "us-west-2c", 152 | "us-west-2d" 153 | ] 154 | }, 155 | { 156 | "platform": "mac", 157 | "version": "14.5", 158 | "arch": "x86", 159 | "repo": "finch", 160 | "desiredInstances": 2, 161 | "availabilityZones": [ 162 | "us-west-2a", 163 | "us-west-2b", 164 | "us-west-2c", 165 | "us-west-2d" 166 | ] 167 | }, 168 | { 169 | "platform": "mac", 170 | "version": "13.6", 171 | "arch": "arm", 172 | "repo": "finch-core", 173 | "desiredInstances": 1, 174 | "availabilityZones": [ 175 | "us-west-2a", 176 | "us-west-2b", 177 | "us-west-2c", 178 | "us-west-2d" 179 | ] 180 | }, 181 | { 182 | "platform": "mac", 183 | "version": "13.6", 184 | "arch": "x86", 185 | "repo": "finch-core", 186 | "desiredInstances": 1, 187 | "availabilityZones": [ 188 | "us-west-2a", 189 | "us-west-2b", 190 | "us-west-2c", 191 | "us-west-2d" 192 | ] 193 | }, 194 | { 195 | "platform": "mac", 196 | "version": "14.5", 197 | "arch": "arm", 198 | "repo": "finch-core", 199 | "desiredInstances": 1, 200 | "availabilityZones": [ 201 | "us-west-2a", 202 | "us-west-2b", 203 | "us-west-2c", 204 | "us-west-2d" 205 | ] 206 | }, 207 | { 208 | "platform": "mac", 209 | "version": "14.5", 210 | "arch": "x86", 211 | "repo": "finch-core", 212 | "desiredInstances": 1, 213 | "availabilityZones": [ 214 | "us-west-2a", 215 | "us-west-2b", 216 | "us-west-2c", 217 | "us-west-2d" 218 | ] 219 | }, 220 | { 221 | "platform": "windows", 222 | "version": "2022", 223 | "arch": "x86", 224 | "repo": "finch", 225 | "desiredInstances": 2, 226 | "availabilityZones": [ 227 | "us-west-2a", 228 | "us-west-2b", 229 | "us-west-2c", 230 | "us-west-2d" 231 | ] 232 | }, 233 | { 234 | "platform": "windows", 235 | "version": "2022", 236 | "arch": "x86", 237 | "repo": "finch-core", 238 | "desiredInstances": 1, 239 | "availabilityZones": [ 240 | "us-west-2a", 241 | "us-west-2b", 242 | "us-west-2c", 243 | "us-west-2d" 244 | ] 245 | }, 246 | { 247 | "platform": "amazonlinux", 248 | "version": "2023", 249 | "arch": "x86", 250 | "repo": "finch", 251 | "desiredInstances": 1, 252 | "availabilityZones": [ 253 | "us-west-2a", 254 | "us-west-2b", 255 | "us-west-2c", 256 | "us-west-2d" 257 | ] 258 | }, 259 | { 260 | "platform": "amazonlinux", 261 | "version": "2", 262 | "arch": "x86", 263 | "repo": "finch", 264 | "desiredInstances": 1, 265 | "availabilityZones": [ 266 | "us-west-2a", 267 | "us-west-2b", 268 | "us-west-2c", 269 | "us-west-2d" 270 | ] 271 | }, 272 | { 273 | "platform": "amazonlinux", 274 | "version": "2023", 275 | "arch": "arm", 276 | "repo": "finch", 277 | "desiredInstances": 1, 278 | "availabilityZones": [ 279 | "us-west-2a", 280 | "us-west-2b", 281 | "us-west-2c", 282 | "us-west-2d" 283 | ] 284 | }, 285 | { 286 | "platform": "amazonlinux", 287 | "version": "2", 288 | "arch": "arm", 289 | "repo": "finch", 290 | "desiredInstances": 1, 291 | "availabilityZones": [ 292 | "us-west-2a", 293 | "us-west-2b", 294 | "us-west-2c", 295 | "us-west-2d" 296 | ] 297 | } 298 | ] 299 | }, 300 | "runnerRelease": { 301 | "macLicenseArn": "arn:aws:license-manager:us-east-2:019528120233:license-configuration:lic-94c3e244cb74a70c90c82f432e9e0d91", 302 | "windowsLicenseArn": "arn:aws:license-manager:us-east-2:019528120233:license-configuration:lic-2ec55a6280292474f1ea0d7ad6f64987", 303 | "runnerTypes": [ 304 | { 305 | "platform": "mac", 306 | "version": "13.6", 307 | "arch": "arm", 308 | "repo": "finch", 309 | "desiredInstances": 1, 310 | "availabilityZones": [ 311 | "us-east-2a", 312 | "us-east-2b", 313 | "us-east-2c" 314 | ] 315 | }, 316 | { 317 | "platform": "mac", 318 | "version": "13.6", 319 | "arch": "x86", 320 | "repo": "finch", 321 | "desiredInstances": 1, 322 | "availabilityZones": [ 323 | "us-east-2a", 324 | "us-east-2b", 325 | "us-east-2c" 326 | ] 327 | }, 328 | { 329 | "platform": "mac", 330 | "version": "14.5", 331 | "arch": "arm", 332 | "repo": "finch", 333 | "desiredInstances": 1, 334 | "availabilityZones": [ 335 | "us-east-2a", 336 | "us-east-2b", 337 | "us-east-2c" 338 | ] 339 | }, 340 | { 341 | "platform": "mac", 342 | "version": "14.5", 343 | "arch": "x86", 344 | "repo": "finch", 345 | "desiredInstances": 1, 346 | "availabilityZones": [ 347 | "us-east-2a", 348 | "us-east-2b", 349 | "us-east-2c" 350 | ] 351 | }, 352 | { 353 | "platform": "mac", 354 | "version": "13.6", 355 | "arch": "arm", 356 | "repo": "finch-core", 357 | "desiredInstances": 1, 358 | "availabilityZones": [ 359 | "us-east-2a", 360 | "us-east-2b", 361 | "us-east-2c" 362 | ] 363 | }, 364 | { 365 | "platform": "mac", 366 | "version": "13.6", 367 | "arch": "x86", 368 | "repo": "finch-core", 369 | "desiredInstances": 1, 370 | "availabilityZones": [ 371 | "us-east-2a", 372 | "us-east-2b", 373 | "us-east-2c" 374 | ] 375 | }, 376 | { 377 | "platform": "mac", 378 | "version": "12.7", 379 | "arch": "arm", 380 | "repo": "finch-core", 381 | "desiredInstances": 1, 382 | "availabilityZones": [ 383 | "us-east-2a", 384 | "us-east-2b", 385 | "us-east-2c" 386 | ] 387 | }, 388 | { 389 | "platform": "mac", 390 | "version": "12.7", 391 | "arch": "x86", 392 | "repo": "finch-core", 393 | "desiredInstances": 1, 394 | "availabilityZones": [ 395 | "us-east-2a", 396 | "us-east-2b", 397 | "us-east-2c" 398 | ] 399 | }, 400 | { 401 | "platform": "windows", 402 | "version": "2022", 403 | "arch": "x86", 404 | "repo": "finch", 405 | "desiredInstances": 1, 406 | "availabilityZones": [ 407 | "us-east-2a", 408 | "us-east-2b", 409 | "us-east-2c" 410 | ] 411 | }, 412 | { 413 | "platform": "windows", 414 | "version": "2022", 415 | "arch": "x86", 416 | "repo": "finch-core", 417 | "desiredInstances": 1, 418 | "availabilityZones": [ 419 | "us-east-2a", 420 | "us-east-2b", 421 | "us-east-2c" 422 | ] 423 | } 424 | ] 425 | } 426 | } -------------------------------------------------------------------------------- /config/runner-config.ts: -------------------------------------------------------------------------------- 1 | import config from './runner-config.json'; 2 | 3 | export interface RunnerProps { 4 | macLicenseArn: string; 5 | windowsLicenseArn: string; 6 | linuxLicenseArn: string; 7 | runnerTypes: Array; 8 | } 9 | 10 | export interface RunnerType { 11 | platform: PlatformType; 12 | /** Different values for different platforms. 13 | * For mac, a version like 13.2 14 | * For windows, a server version like 2022 15 | * For amazonlinux, either 2, 2023, etc. 16 | * For fedora, a version like 40, 41, etc. */ 17 | version: string; 18 | arch: string; 19 | repo: string; 20 | desiredInstances: number; 21 | availabilityZones: Array; 22 | } 23 | 24 | export const enum PlatformType { 25 | WINDOWS = 'windows', 26 | MAC = 'mac', 27 | AMAZONLINUX = 'amazonlinux', 28 | } 29 | 30 | /** 31 | * Class for runner configurations. Outlines self hosted license arn and an 32 | * array of runner types to create using an auto scaling group. 33 | */ 34 | class RunnerConfigClass { 35 | public readonly runnerBeta: RunnerProps; 36 | public readonly runnerProd: RunnerProps; 37 | public readonly runnerRelease: RunnerProps; 38 | 39 | constructor(configFile: any) { 40 | if (!configFile.runnerBeta) { 41 | throw new Error('Error: Beta runner config must be specified.'); 42 | } 43 | this.runnerBeta = configFile.runnerBeta; 44 | 45 | if (!configFile.runnerProd) { 46 | throw new Error('Error: Prod runner config must be specified.'); 47 | } 48 | this.runnerProd = configFile.runnerProd; 49 | 50 | if (!configFile.runnerRelease) { 51 | throw new Error('Error: Release runner config must be specified.'); 52 | } 53 | this.runnerRelease = configFile.runnerRelease; 54 | } 55 | } 56 | 57 | export const RunnerConfig = new RunnerConfigClass(config); 58 | -------------------------------------------------------------------------------- /integration_test/cloudfront_service.test.ts: -------------------------------------------------------------------------------- 1 | describe('Integration test', () => { 2 | test('200 Response from CloudFront Distribution', async () => { 3 | const url = process.env.CLOUDFRONT_URL ? `https://${process.env.CLOUDFRONT_URL}/test.html` : 'No URL in env'; 4 | console.log('CloudFront URL =>', url); 5 | try { 6 | const response = await fetch(url, { 7 | method: 'GET', 8 | headers: { 9 | Accept: 'application/json' 10 | } 11 | }); 12 | if (!response.ok) { 13 | throw new Error(`Request failed ${response.statusText}`); 14 | } 15 | const body = await response.text(); 16 | console.log('Response body =>', body); 17 | 18 | expect(response.status).toEqual(200); 19 | } catch (err) { 20 | console.log('ERROR: ', err); 21 | throw err; 22 | } 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /jest.config.integration.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: [ 4 | '/integration_test', 5 | ], 6 | testMatch: ['**/*.test.ts'], 7 | transform: { 8 | '^.+\\.tsx?$': 'ts-jest' 9 | } 10 | }; -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: [ 4 | '/test', 5 | '/integration_test', 6 | ], 7 | testMatch: ['**/*.test.ts'], 8 | transform: { 9 | '^.+\\.tsx?$': 'ts-jest' 10 | } 11 | }; -------------------------------------------------------------------------------- /jest.config.unit.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: [ 4 | '/test', 5 | ], 6 | testMatch: ['**/*.test.ts'], 7 | transform: { 8 | '^.+\\.tsx?$': 'ts-jest' 9 | } 10 | }; -------------------------------------------------------------------------------- /lib/artifact-bucket-cloudfront.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { CfnOutput } from 'aws-cdk-lib'; 3 | import * as s3 from 'aws-cdk-lib/aws-s3'; 4 | import { Construct } from 'constructs'; 5 | 6 | import { CloudfrontCdn } from './cloudfront_cdn'; 7 | import * as s3Deployment from 'aws-cdk-lib/aws-s3-deployment'; 8 | import { applyTerminationProtectionOnStacks } from './aspects/stack-termination-protection'; 9 | 10 | export class ArtifactBucketCloudfrontStack extends cdk.Stack { 11 | public readonly urlOutput: CfnOutput; 12 | public readonly bucket: s3.Bucket; 13 | constructor(scope: Construct, id: string, stage: string, props?: cdk.StackProps) { 14 | super(scope, id, props); 15 | applyTerminationProtectionOnStacks([this]); 16 | 17 | const bucketName = `finch-artifact-bucket-${stage.toLowerCase()}-${cdk.Stack.of(this)?.account}`; 18 | const artifactBucket = new s3.Bucket(this, 'ArtifactBucket', { 19 | bucketName, 20 | publicReadAccess: false, 21 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL 22 | }); 23 | 24 | // upload the file for integration testing puporse 25 | new s3Deployment.BucketDeployment(this, 'DeployTestFile', { 26 | sources: [s3Deployment.Source.asset('./assets')], 27 | destinationBucket: artifactBucket 28 | }); 29 | 30 | const cloudfrontCdn = new CloudfrontCdn(this, 'ArtifactCloudfrontCdn', { 31 | bucket: artifactBucket 32 | }); 33 | this.bucket = artifactBucket; 34 | this.urlOutput = cloudfrontCdn.urlOutput; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/asg-runner-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { aws_autoscaling as autoscaling } from 'aws-cdk-lib'; 3 | import { UpdatePolicy } from 'aws-cdk-lib/aws-autoscaling'; 4 | import * as ec2 from 'aws-cdk-lib/aws-ec2'; 5 | import * as iam from 'aws-cdk-lib/aws-iam'; 6 | import * as resourcegroups from 'aws-cdk-lib/aws-resourcegroups'; 7 | import { Construct } from 'constructs'; 8 | import { readFileSync } from 'fs'; 9 | import { PlatformType, RunnerType } from '../config/runner-config'; 10 | import { applyTerminationProtectionOnStacks } from './aspects/stack-termination-protection'; 11 | import { ENVIRONMENT_STAGE } from './finch-pipeline-app-stage'; 12 | 13 | interface IASGRunnerStack { 14 | platform: PlatformType; 15 | version: string; 16 | arch: string; 17 | repo: string; 18 | } 19 | 20 | interface ASGRunnerStackProps extends cdk.StackProps { 21 | env: cdk.Environment | undefined; 22 | stage: ENVIRONMENT_STAGE; 23 | /** Only required for dedicated hosts. 24 | * Right now, dedicated hosts should only be used to avoid 25 | * nested virtualization issues, which is only a problem for 26 | * non-Linux usecases. */ 27 | licenseArn?: string; 28 | type: RunnerType; 29 | } 30 | 31 | /** 32 | * A stack to provision an autoscaling group for macOS instances. This requires: 33 | * - a self-managed license (manually created as cdk/cfn does not support this) 34 | * - a resource group 35 | * - a launch template 36 | * - an auto scaling group 37 | */ 38 | export class ASGRunnerStack extends cdk.Stack implements IASGRunnerStack { 39 | platform: PlatformType; 40 | version: string; 41 | arch: string; 42 | repo: string; 43 | 44 | requiresDedicatedHosts = () => this.platform === PlatformType.MAC || this.platform === PlatformType.WINDOWS; 45 | 46 | userData = (props: ASGRunnerStackProps, setupScriptName: string) => 47 | `#!/bin/bash 48 | LABEL_STAGE=${props.stage === ENVIRONMENT_STAGE.Release ? 'release' : 'test'} 49 | REPO=${this.repo} 50 | REGION=${props.env?.region} 51 | ` + readFileSync(`./scripts/${setupScriptName}`, 'utf8'); 52 | 53 | constructor(scope: Construct, id: string, props: ASGRunnerStackProps) { 54 | super(scope, id, props); 55 | 56 | this.platform = props.type.platform; 57 | this.version = props.type.version; 58 | this.arch = props.type.arch; 59 | this.repo = props.type.repo; 60 | 61 | applyTerminationProtectionOnStacks([this]); 62 | 63 | const amiSearchString = `amzn-ec2-macos-${this.version}*`; 64 | 65 | let instanceType: ec2.InstanceType; 66 | let machineImage: ec2.IMachineImage; 67 | let userDataString = ''; 68 | let asgName = ''; 69 | let rootDeviceName = ''; 70 | switch (this.platform) { 71 | case PlatformType.MAC: { 72 | rootDeviceName = '/dev/sda1'; 73 | if (this.arch === 'arm') { 74 | instanceType = ec2.InstanceType.of(ec2.InstanceClass.MAC2, ec2.InstanceSize.METAL); 75 | } else { 76 | instanceType = ec2.InstanceType.of(ec2.InstanceClass.MAC1, ec2.InstanceSize.METAL); 77 | } 78 | const macOSArchLookup = this.arch === 'arm' ? `arm64_${this.platform}` : `x86_64_${this.platform}`; 79 | machineImage = new ec2.LookupMachineImage({ 80 | name: amiSearchString, 81 | filters: { 82 | 'virtualization-type': ['hvm'], 83 | 'root-device-type': ['ebs'], 84 | architecture: [macOSArchLookup], 85 | 'owner-alias': ['amazon'] 86 | } 87 | }); 88 | asgName = 'MacASG'; 89 | userDataString = this.userData(props, 'setup-runner.sh'); 90 | break; 91 | } 92 | case PlatformType.WINDOWS: { 93 | instanceType = ec2.InstanceType.of(ec2.InstanceClass.M5ZN, ec2.InstanceSize.METAL); 94 | asgName = 'WindowsASG'; 95 | rootDeviceName = '/dev/sda1'; 96 | machineImage = ec2.MachineImage.latestWindows(ec2.WindowsVersion.WINDOWS_SERVER_2022_ENGLISH_FULL_BASE); 97 | // We need to provide user data as a yaml file to specify runAs: admin 98 | // Maintain that file as yaml and source here to ensure formatting. 99 | userDataString = readFileSync('./scripts/windows-runner-user-data.yaml', 'utf8') 100 | .replace('', props.stage === ENVIRONMENT_STAGE.Release ? 'release' : 'test') 101 | .replace('', props.type.repo) 102 | .replace('', props.env?.region || ''); 103 | 104 | break; 105 | } 106 | case PlatformType.AMAZONLINUX: { 107 | // Linux instances do not have to be metal, since the only mode of operation 108 | // for Finch on linux currently is "native" mode, e.g. no virutal machine on host 109 | 110 | rootDeviceName = '/dev/xvda'; 111 | let cpuType: ec2.AmazonLinuxCpuType; 112 | if (this.arch === 'arm') { 113 | instanceType = ec2.InstanceType.of(ec2.InstanceClass.C7G, ec2.InstanceSize.LARGE); 114 | cpuType = ec2.AmazonLinuxCpuType.ARM_64; 115 | } else { 116 | instanceType = ec2.InstanceType.of(ec2.InstanceClass.C7A, ec2.InstanceSize.LARGE); 117 | cpuType = ec2.AmazonLinuxCpuType.X86_64; 118 | } 119 | asgName = 'LinuxASG'; 120 | userDataString = this.userData(props, 'setup-linux-runner.sh'); 121 | if (this.version === '2') { 122 | machineImage = ec2.MachineImage.latestAmazonLinux2({ 123 | cpuType 124 | }); 125 | } else { 126 | machineImage = ec2.MachineImage.latestAmazonLinux2023({ 127 | cpuType 128 | }); 129 | } 130 | break; 131 | } 132 | } 133 | 134 | if (props.env == undefined) { 135 | throw new Error('Runner environment is undefined!'); 136 | } 137 | 138 | const vpc = cdk.aws_ec2.Vpc.fromLookup(this, 'VPC', { isDefault: true }); 139 | 140 | const securityGroup = new ec2.SecurityGroup(this, 'EC2SecurityGroup', { 141 | vpc, 142 | description: 'Allow only outbound traffic', 143 | allowAllOutbound: true 144 | }); 145 | 146 | const role = new iam.Role(this, 'EC2Role', { 147 | assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com') 148 | }); 149 | 150 | role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')); 151 | role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AutoScalingFullAccess')); 152 | role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('ResourceGroupsandTagEditorFullAccess')); 153 | 154 | // Grant EC2 instances access to secretsmanager to retrieve the GitHub api key to register runners 155 | role.addToPolicy( 156 | new iam.PolicyStatement({ 157 | effect: iam.Effect.ALLOW, 158 | actions: [ 159 | 'secretsmanager:GetResourcePolicy', 160 | 'secretsmanager:GetSecretValue', 161 | 'secretsmanager:DescribeSecret', 162 | 'secretsmanager:ListSecretVersionIds' 163 | ], 164 | resources: [ 165 | `arn:aws:secretsmanager:${props.env?.region}:${props.env?.account}:secret:${props.type.repo}-runner-reg-key*` 166 | ] 167 | }) 168 | ); 169 | 170 | // Create a 100GiB volume to be used as instance root volume 171 | const rootVolume: ec2.BlockDevice = { 172 | deviceName: rootDeviceName, 173 | volume: ec2.BlockDeviceVolume.ebs(100, { 174 | volumeType: ec2.EbsDeviceVolumeType.GP3, 175 | // throughput / 256 KiB per operation 176 | // default is size * 3 177 | iops: 1200, 178 | throughput: 300 179 | }) 180 | }; 181 | 182 | const ltName = `${asgName}LaunchTemplate`; 183 | const keyPairName = `${asgName}KeyPair`; 184 | const lt = new ec2.LaunchTemplate(this, ltName, { 185 | requireImdsv2: true, 186 | instanceType, 187 | keyPair: ec2.KeyPair.fromKeyPairName(this, keyPairName, 'runner-key'), 188 | machineImage, 189 | role: role, 190 | securityGroup: securityGroup, 191 | userData: ec2.UserData.custom(userDataString), 192 | blockDevices: [rootVolume] 193 | }); 194 | 195 | // Create a custom name for this as names for resource groups cannot be repeated 196 | const resourceGroupName = `${this.repo}-${this.platform}-${this.version.split('.')[0]}-${this.arch}HostGroup`; 197 | const resourceGroupDescription = 'Host resource group for finchs infrastructure'; 198 | 199 | let ltPlacementConfig = {}; 200 | if (this.requiresDedicatedHosts()) { 201 | const hostResourceGroup = this.createHostResourceGroup(resourceGroupName, resourceGroupDescription); 202 | ltPlacementConfig = { 203 | placement: { 204 | tenancy: 'host', 205 | hostResourceGroupArn: hostResourceGroup.attrArn 206 | } 207 | }; 208 | } 209 | 210 | // Escape hatch to cfnLaunchTemplate as the L2 construct lacked some required 211 | // configurations. 212 | const cfnLt = lt.node.defaultChild as ec2.CfnLaunchTemplate; 213 | cfnLt.launchTemplateData = { 214 | ...cfnLt.launchTemplateData, 215 | ...(this.requiresDedicatedHosts() && { 216 | ...ltPlacementConfig, 217 | licenseSpecifications: [{ licenseConfigurationArn: props.licenseArn }] 218 | }), 219 | tagSpecifications: [ 220 | { 221 | resourceType: 'instance', 222 | tags: [ 223 | { 224 | key: 'PVRE-Reporting', 225 | value: 'SSM' 226 | } 227 | ] 228 | } 229 | ] 230 | }; 231 | 232 | const asg = new autoscaling.AutoScalingGroup(this, asgName, { 233 | vpc, 234 | desiredCapacity: props.type.desiredInstances, 235 | maxCapacity: props.type.desiredInstances, 236 | minCapacity: 0, 237 | healthCheck: autoscaling.HealthCheck.ec2({ 238 | grace: cdk.Duration.seconds(3600) 239 | }), 240 | launchTemplate: lt, 241 | updatePolicy: UpdatePolicy.rollingUpdate({ 242 | // Defaults shown here explicitly except for pauseTime 243 | // and minSuccesPercentage 244 | maxBatchSize: 1, 245 | minInstancesInService: 0, 246 | suspendProcesses: [ 247 | autoscaling.ScalingProcess.HEALTH_CHECK, 248 | autoscaling.ScalingProcess.REPLACE_UNHEALTHY, 249 | autoscaling.ScalingProcess.AZ_REBALANCE, 250 | autoscaling.ScalingProcess.ALARM_NOTIFICATION, 251 | autoscaling.ScalingProcess.SCHEDULED_ACTIONS 252 | ], 253 | waitOnResourceSignals: false 254 | }) 255 | }); 256 | 257 | if (!this.requiresDedicatedHosts()) { 258 | this.createTagBasedResourceGroup(resourceGroupName, resourceGroupDescription, asg.autoScalingGroupName); 259 | } 260 | 261 | if (props.stage === ENVIRONMENT_STAGE.Beta) { 262 | new autoscaling.CfnScheduledAction(this, 'SpinDownBetaInstances', { 263 | autoScalingGroupName: asg.autoScalingGroupName, 264 | // 1 day from now 265 | startTime: new Date(new Date().getTime() + 1 * 24 * 60 * 60 * 1000).toISOString(), 266 | desiredCapacity: 0 267 | }); 268 | } 269 | } 270 | 271 | // a host resource group is used by the launch template for placement of instances on dedicated hosts 272 | createHostResourceGroup(resourceGroupName: string, resourceGroupDescription: string) { 273 | return new resourcegroups.CfnGroup(this, resourceGroupName, { 274 | name: resourceGroupName, 275 | description: resourceGroupDescription, 276 | configuration: [ 277 | { 278 | // This resource group is only used for management of dedicated hosts, as indicated by 279 | // the "AWS::EC2::HostManagement" type 280 | type: 'AWS::EC2::HostManagement', 281 | parameters: [ 282 | { 283 | name: 'auto-allocate-host', 284 | values: ['true'] 285 | }, 286 | { 287 | name: 'auto-release-host', 288 | values: ['true'] 289 | }, 290 | { 291 | name: 'any-host-based-license-configuration', 292 | values: ['true'] 293 | } 294 | ] 295 | }, 296 | { 297 | type: 'AWS::ResourceGroups::Generic', 298 | parameters: [ 299 | { 300 | name: 'allowed-resource-types', 301 | values: ['AWS::EC2::Host'] 302 | }, 303 | { 304 | name: 'deletion-protection', 305 | values: ['UNLESS_EMPTY'] 306 | } 307 | ] 308 | } 309 | ] 310 | }); 311 | } 312 | 313 | // tag based resource groups filter EC2 instances by tag, anything matching will be included in the group 314 | createTagBasedResourceGroup(resourceGroupName: string, resourceGroupDescription: string, asgName: string) { 315 | return new resourcegroups.CfnGroup(this, resourceGroupName, { 316 | name: resourceGroupName, 317 | description: resourceGroupDescription, 318 | resourceQuery: { 319 | type: 'TAG_FILTERS_1_0', 320 | query: { 321 | resourceTypeFilters: ['AWS::EC2::Instance'], 322 | tagFilters: [ 323 | { 324 | key: 'aws:autoscaling:groupName', 325 | values: [asgName] 326 | } 327 | ] 328 | } 329 | } 330 | }); 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /lib/aspects/stack-termination-protection.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Finch Infrastructure 4 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 5 | 6 | import { Aspects, IAspect, Stack } from "aws-cdk-lib"; 7 | import { IConstruct } from "constructs"; 8 | 9 | /** 10 | * Enable termination protection in all stacks. 11 | */ 12 | export class EnableTerminationProtectionOnStacks implements IAspect { 13 | visit(construct: IConstruct): void { 14 | if (Stack.isStack(construct)) { 15 | (construct).terminationProtection = true; 16 | } 17 | } 18 | } 19 | 20 | export function applyTerminationProtectionOnStacks(constructs: IConstruct[]) { 21 | constructs.forEach((construct) => { 22 | Aspects.of(construct).add(new EnableTerminationProtectionOnStacks()); 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /lib/cloudfront_cdn.ts: -------------------------------------------------------------------------------- 1 | import * as s3 from 'aws-cdk-lib/aws-s3'; 2 | import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; 3 | import * as cloudfront_origins from 'aws-cdk-lib/aws-cloudfront-origins'; 4 | import * as iam from 'aws-cdk-lib/aws-iam'; 5 | import { Construct } from 'constructs'; 6 | import { CfnOutput, Stack } from 'aws-cdk-lib'; 7 | 8 | interface CloudfrontCdnProps { 9 | bucket: s3.Bucket; 10 | } 11 | 12 | export class CloudfrontCdn extends Construct { 13 | public readonly urlOutput: CfnOutput; 14 | constructor(parent: Stack, id: string, props: CloudfrontCdnProps) { 15 | super(parent, id); 16 | 17 | const cloudfrontOAI = new cloudfront.OriginAccessIdentity(this, 'cloudfront-OAI', { 18 | comment: 'OAI for artifact bucket cloudfront' 19 | }); 20 | 21 | props.bucket.addToResourcePolicy( 22 | new iam.PolicyStatement({ 23 | actions: ['s3:GetObject'], 24 | resources: [props.bucket.arnForObjects('*')], 25 | principals: [new iam.CanonicalUserPrincipal(cloudfrontOAI.cloudFrontOriginAccessIdentityS3CanonicalUserId)] 26 | }) 27 | ); 28 | new CfnOutput(this, 'S3 bucket', { value: props.bucket.bucketName }); 29 | 30 | const distribution = new cloudfront.Distribution(this, 'ArtifactBucketDistribution', { 31 | defaultBehavior: { 32 | origin: new cloudfront_origins.S3Origin(props.bucket, { 33 | originAccessIdentity: cloudfrontOAI 34 | }), 35 | viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, 36 | allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD 37 | } 38 | }); 39 | new CfnOutput(this, 'Distribution Id', { 40 | value: distribution.distributionId 41 | }); 42 | this.urlOutput = new CfnOutput(this, 'Distribution Domain', { 43 | value: distribution.domainName 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/codebuild-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import * as codebuild from 'aws-cdk-lib/aws-codebuild'; 3 | import * as ec2 from 'aws-cdk-lib/aws-ec2'; 4 | import * as iam from 'aws-cdk-lib/aws-iam'; 5 | import { Key } from 'aws-cdk-lib/aws-kms'; 6 | import { Construct } from 'constructs'; 7 | import { 8 | BuildImageOS, 9 | GITHUB_ALLOWLISTED_ACCOUNT_IDS, 10 | LinuxAMIBuildImage, 11 | MacAMIBuildImage, 12 | toStackName, 13 | WindowsAMIBuildImage 14 | } from './utils'; 15 | 16 | const webhookFiltersArr: codebuild.FilterGroup[] = []; 17 | 18 | for (const userId of GITHUB_ALLOWLISTED_ACCOUNT_IDS) { 19 | console.log('creating filter group for userid: ', userId); 20 | webhookFiltersArr.push( 21 | codebuild.FilterGroup.inEventOf(codebuild.EventAction.WORKFLOW_JOB_QUEUED).andActorAccountIs(userId) 22 | ); 23 | } 24 | 25 | const githHubSource = codebuild.Source.gitHub({ 26 | owner: 'runfinch', 27 | repo: 'finch', 28 | webhook: true, 29 | webhookFilters: webhookFiltersArr, 30 | fetchSubmodules: true, 31 | cloneDepth: 0, 32 | }); 33 | 34 | interface ImageFilterProps { 35 | 'virtualization-type': string[]; 36 | 'root-device-type': string[]; 37 | 'owner-alias': string[]; 38 | } 39 | 40 | /** 41 | * Default properties for CodeBuildStack configuration. 42 | * Contains static readonly properties that define default values for image filters, 43 | * fleet configuration, and project environment settings. 44 | */ 45 | class CodeBuildStackDefaultProps { 46 | static readonly imageFilterProps: ImageFilterProps = { 47 | 'virtualization-type': ['hvm'], 48 | 'root-device-type': ['ebs'], 49 | 'owner-alias': ['amazon'] 50 | }; 51 | static readonly fleetProps = { 52 | computeType: codebuild.FleetComputeType.MEDIUM, 53 | baseCapacity: 1 54 | }; 55 | static readonly projectEnvironmentProps = { 56 | computeType: codebuild.ComputeType.MEDIUM 57 | }; 58 | static readonly terminationProtection: boolean = true; 59 | } 60 | 61 | class CodeBuildStackProps { 62 | env: cdk.Environment | undefined; 63 | projectName: string; 64 | region: string; 65 | arch: string; 66 | operatingSystem: string; 67 | amiSearchString: string; 68 | environmentType: codebuild.EnvironmentType; 69 | buildImageOS: BuildImageOS; 70 | imageFilterProps?: ImageFilterProps; 71 | fleetProps?: { 72 | computeType: codebuild.FleetComputeType; 73 | baseCapacity: number; 74 | }; 75 | projectEnvironmentProps?: { 76 | computeType: codebuild.ComputeType; 77 | }; 78 | } 79 | 80 | export class CodeBuildStack extends cdk.Stack { 81 | constructor(scope: Construct, id: string, props: CodeBuildStackProps) { 82 | super(scope, id, { 83 | ...props, 84 | terminationProtection: CodeBuildStackDefaultProps.terminationProtection, 85 | }); 86 | this.createBuildProject(props, id); 87 | } 88 | 89 | private createBuildProject(props: CodeBuildStackProps, id: string): codebuild.Project { 90 | const platformId: string = `${props.operatingSystem}-${toStackName(props.arch)}`; 91 | 92 | const secretArn = this.formatArn({ 93 | service: 'secretsmanager', 94 | resource: 'secret', 95 | resourceName: `codebuild-github-access-token-??????`, 96 | arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME 97 | }); 98 | 99 | const machineImageProps = { 100 | name: props.amiSearchString, 101 | filters: { 102 | ...(props.imageFilterProps || CodeBuildStackDefaultProps.imageFilterProps), 103 | architecture: [props.arch] 104 | } 105 | }; 106 | const machineImage = new ec2.LookupMachineImage(machineImageProps); 107 | 108 | const fleetServiceRole = new iam.Role(this, `FleetServiceRole-${platformId}`, { 109 | assumedBy: new iam.ServicePrincipal('codebuild.amazonaws.com'), 110 | managedPolicies: [ 111 | iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonEC2FullAccess'), 112 | iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore') 113 | ] 114 | }); 115 | 116 | const fleet = new codebuild.Fleet(this, `Fleet-${toStackName(props.arch)}`, { 117 | ...(props.fleetProps || CodeBuildStackDefaultProps.fleetProps), 118 | environmentType: props.environmentType 119 | }); 120 | 121 | const imageId: string = machineImage.getImage(this).imageId; 122 | 123 | const cfnFleet = fleet.node.defaultChild as cdk.CfnResource; 124 | cfnFleet.addPropertyOverride('ImageId', imageId); 125 | cfnFleet.addPropertyOverride('FleetServiceRole', fleetServiceRole.roleArn); 126 | 127 | const codebuildProject = new codebuild.Project(this, id, { 128 | projectName: props.projectName, 129 | source: githHubSource, 130 | environment: { 131 | ...(props.projectEnvironmentProps || CodeBuildStackDefaultProps.projectEnvironmentProps), 132 | fleet: fleet, 133 | buildImage: this.getBuildImageByOS(props.buildImageOS, props.environmentType, imageId) 134 | }, 135 | encryptionKey: new Key(this, `codebuild-${platformId}-key-${props.region}`, { 136 | description: 'Kms Key to encrypt data-at-rest', 137 | alias: `finch-${platformId}-kms-${props.region}`, 138 | enabled: true 139 | }) 140 | }); 141 | 142 | codebuildProject.addToRolePolicy( 143 | new iam.PolicyStatement({ 144 | actions: ['secretsmanager:GetSecretValue'], 145 | resources: [secretArn] 146 | }) 147 | ); 148 | 149 | return codebuildProject; 150 | } 151 | 152 | private getBuildImageByOS( 153 | os: BuildImageOS, 154 | environmentType: codebuild.EnvironmentType, 155 | imageId: string 156 | ): cdk.aws_codebuild.IBuildImage { 157 | switch (os) { 158 | case BuildImageOS.LINUX: 159 | return new LinuxAMIBuildImage(imageId, environmentType); 160 | case BuildImageOS.MAC: 161 | return new MacAMIBuildImage(imageId, environmentType); 162 | case BuildImageOS.WINDOWS: 163 | return new WindowsAMIBuildImage(imageId, environmentType); 164 | default: 165 | throw new Error(`Unsupported Build Image OS: ${os}`); 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /lib/continuous-integration-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import * as ecr from 'aws-cdk-lib/aws-ecr'; 3 | import * as s3 from 'aws-cdk-lib/aws-s3'; 4 | import * as iam from 'aws-cdk-lib/aws-iam'; 5 | import { Construct } from 'constructs'; 6 | 7 | import { CloudfrontCdn } from './cloudfront_cdn'; 8 | import { applyTerminationProtectionOnStacks } from './aspects/stack-termination-protection'; 9 | 10 | interface ContinuousIntegrationStackProps extends cdk.StackProps { 11 | rootfsEcrRepository: ecr.Repository; 12 | } 13 | 14 | // ContinuousIntegrationStack - AWS stack for supporting Finch's continuous integration process 15 | export class ContinuousIntegrationStack extends cdk.Stack { 16 | constructor(scope: Construct, id: string, stage: string, props: ContinuousIntegrationStackProps) { 17 | super(scope, id, props); 18 | applyTerminationProtectionOnStacks([this]); 19 | 20 | const githubDomain = 'token.actions.githubusercontent.com'; 21 | 22 | const ghProvider = new iam.OpenIdConnectProvider(this, 'githubProvider', { 23 | url: `https://${githubDomain}`, 24 | clientIds: ['sts.amazonaws.com'] 25 | }); 26 | 27 | const githubActionsRole = new iam.Role(this, 'GithubActionsRole', { 28 | assumedBy: new iam.WebIdentityPrincipal(ghProvider.openIdConnectProviderArn), 29 | roleName: 'GithubActionsRole', 30 | description: 'This role is used by GitHub Actions', 31 | maxSessionDuration: cdk.Duration.hours(1) 32 | }); 33 | 34 | // Override docs: https://docs.aws.amazon.com/cdk/v2/guide/cfn_layer.html#cfn_layer_raw 35 | // Condition from: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html#idp_oidc_Create_GitHub 36 | const cfnRole = githubActionsRole.node.defaultChild as iam.CfnRole; 37 | cfnRole.addOverride('Properties.AssumeRolePolicyDocument.Statement.0.Condition', { 38 | StringLike: { 39 | 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com', 40 | 'token.actions.githubusercontent.com:sub': 'repo:runfinch/*' 41 | } 42 | }); 43 | 44 | const bucketName = `finch-dependencies-${stage.toLowerCase()}-${cdk.Stack.of(this)?.account}`; 45 | 46 | const bucket = new s3.Bucket(this, 'Dependencies', { 47 | bucketName, 48 | publicReadAccess: false, 49 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL 50 | }); 51 | bucket.grantReadWrite(githubActionsRole); 52 | 53 | const repo = props.rootfsEcrRepository; 54 | repo.grantPullPush(githubActionsRole); 55 | ecr.AuthorizationToken.grantRead(githubActionsRole) 56 | 57 | new CloudfrontCdn(this, 'DependenciesCloudfrontCdn', { 58 | bucket 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/ecr-repo-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { CfnOutput } from 'aws-cdk-lib'; 3 | import * as ecr from 'aws-cdk-lib/aws-ecr'; 4 | import { Construct } from 'constructs'; 5 | import { applyTerminationProtectionOnStacks } from './aspects/stack-termination-protection'; 6 | 7 | export class ECRRepositoryStack extends cdk.Stack { 8 | public readonly repositoryOutput: CfnOutput; 9 | public readonly repository: ecr.Repository; 10 | constructor(scope: Construct, id: string, stage: string, props?: cdk.StackProps) { 11 | super(scope, id, props); 12 | applyTerminationProtectionOnStacks([this]); 13 | 14 | const repoName = `finch-rootfs-image-${stage.toLowerCase()}`; 15 | const ecrRepository = new ecr.Repository(this, 'finch-rootfs', { 16 | repositoryName:repoName, 17 | imageTagMutability: ecr.TagMutability.IMMUTABLE, 18 | // TODO: CFN does not provide APIs for enhanced image scanning. 19 | // To address, create a custom stack that uses the AWS sdk to change the account ECR 20 | // scanning settings to enhanced. 21 | 22 | // For now, scan on image push is set to true. This means that the image will be scanned 23 | // for vulnerabilites every time it is pushed up to the ECR repo. With enhanced scanning, 24 | // the image would be continously scanned for vulnerabilities. 25 | // See https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-scanning-enhanced.html 26 | imageScanOnPush: true, 27 | }); 28 | 29 | this.repository = ecrRepository 30 | this.repositoryOutput = new CfnOutput(this, 'ECR repository', { value: ecrRepository.repositoryName }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/event-bridge-scan-notifs-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import * as events from 'aws-cdk-lib/aws-events'; 3 | import * as iam from 'aws-cdk-lib/aws-iam'; 4 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 5 | import * as sns from 'aws-cdk-lib/aws-sns'; 6 | import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions'; 7 | import * as targets from 'aws-cdk-lib/aws-events-targets'; 8 | import { Construct } from 'constructs'; 9 | import path from 'path'; 10 | import { applyTerminationProtectionOnStacks } from './aspects/stack-termination-protection'; 11 | 12 | export class EventBridgeScanNotifsStack extends cdk.Stack { 13 | constructor(scope: Construct, id: string, stage: string, props?: cdk.StackProps) { 14 | super(scope, id, props); 15 | applyTerminationProtectionOnStacks([this]); 16 | 17 | const topic = new sns.Topic(this, 'ECR Image Inspector Findings'); 18 | 19 | // Let's not expose this on GitHub, will only be visible in AWS logs Finch team owns, which is low risk. 20 | // Secret has to be created only in Prod account. 21 | // unsafeUnwrap is used because SNS does not have any construct that accepts a SecretValue property. 22 | const securityEmail = cdk.SecretValue.secretsManager('security-notifications-email').unsafeUnwrap() 23 | topic.addSubscription(new subscriptions.EmailSubscription(securityEmail.toString())); 24 | 25 | const notificationFn = new lambda.Function(this, 'SendECRImageInspectorFindings', { 26 | runtime: lambda.Runtime.PYTHON_3_11, 27 | handler: 'main.lambda_handler', 28 | code: lambda.Code.fromAsset(path.join(__dirname, 'image-scanning-notifications-lambda-handler')), 29 | environment: {'SNS_ARN': topic.topicArn,}, 30 | }); 31 | 32 | const snsTopicPolicy = new iam.PolicyStatement({ 33 | actions: ['sns:publish'], 34 | resources: ['*'], 35 | }); 36 | 37 | notificationFn.addToRolePolicy(snsTopicPolicy); 38 | 39 | // Only publish CRITICAL and HIGH findings (more than 7.0 CVE score) that are ACTIVE 40 | // https://docs.aws.amazon.com/inspector/latest/user/findings-understanding-severity.html 41 | const rule = new events.Rule(this, 'rule', { 42 | eventPattern: { 43 | source: ['aws.inspector2'], 44 | detail: { 45 | severity: ['HIGH', 'CRITICAL'], 46 | status: events.Match.exactString('ACTIVE') 47 | }, 48 | detailType: events.Match.exactString('Inspector2 Finding'), 49 | }, 50 | }); 51 | 52 | rule.addTarget(new targets.LambdaFunction(notificationFn)) 53 | } 54 | } -------------------------------------------------------------------------------- /lib/finch-pipeline-app-stage.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { CfnOutput } from 'aws-cdk-lib'; 3 | import * as codebuild from 'aws-cdk-lib/aws-codebuild'; 4 | import * as ecr from 'aws-cdk-lib/aws-ecr'; 5 | import * as s3 from 'aws-cdk-lib/aws-s3'; 6 | import { Construct } from 'constructs'; 7 | import { PlatformType, RunnerProps } from '../config/runner-config'; 8 | import { ArtifactBucketCloudfrontStack } from './artifact-bucket-cloudfront'; 9 | import { ASGRunnerStack } from './asg-runner-stack'; 10 | import { applyTerminationProtectionOnStacks } from './aspects/stack-termination-protection'; 11 | import { CodeBuildStack } from './codebuild-stack'; 12 | import { ContinuousIntegrationStack } from './continuous-integration-stack'; 13 | import { ECRRepositoryStack } from './ecr-repo-stack'; 14 | import { EventBridgeScanNotifsStack } from './event-bridge-scan-notifs-stack'; 15 | import { PVREReportingStack } from './pvre-reporting-stack'; 16 | import { SSMPatchingStack } from './ssm-patching-stack'; 17 | import { CODEBUILD_STACKS, toStackName } from './utils'; 18 | 19 | export enum ENVIRONMENT_STAGE { 20 | Beta, 21 | Prod, 22 | Release 23 | } 24 | 25 | interface FinchPipelineAppStageProps extends cdk.StageProps { 26 | environmentStage: ENVIRONMENT_STAGE; 27 | runnerConfig: RunnerProps; 28 | } 29 | 30 | export class FinchPipelineAppStage extends cdk.Stage { 31 | artifactBucketCloudfrontUrlOutput: CfnOutput; 32 | ecrRepositoryOutput: CfnOutput; 33 | public readonly cloudfrontBucket: s3.Bucket; 34 | public readonly ecrRepository: ecr.Repository; 35 | 36 | constructor(scope: Construct, id: string, props: FinchPipelineAppStageProps) { 37 | super(scope, id, props); 38 | applyTerminationProtectionOnStacks([this]); 39 | props.runnerConfig.runnerTypes.forEach((runnerType) => { 40 | const ASGStackName = `ASG-${runnerType.platform}-${runnerType.repo}-${runnerType.version.split('.')[0]}-${runnerType.arch}Stack`; 41 | let licenseArn: string | undefined; 42 | switch (runnerType.platform) { 43 | case PlatformType.MAC: { 44 | licenseArn = props.runnerConfig.macLicenseArn; 45 | break; 46 | } 47 | case PlatformType.WINDOWS: { 48 | licenseArn = props.runnerConfig.windowsLicenseArn; 49 | break; 50 | } 51 | } 52 | new ASGRunnerStack(this, ASGStackName, { 53 | env: props.env, 54 | stage: props.environmentStage, 55 | type: runnerType, 56 | licenseArn 57 | }); 58 | }); 59 | 60 | if (props.environmentStage !== ENVIRONMENT_STAGE.Release) { 61 | const artifactBucketCloudfrontStack = new ArtifactBucketCloudfrontStack( 62 | this, 63 | 'ArtifactCloudfront', 64 | this.stageName 65 | ); 66 | this.artifactBucketCloudfrontUrlOutput = artifactBucketCloudfrontStack.urlOutput; 67 | this.cloudfrontBucket = artifactBucketCloudfrontStack.bucket; 68 | 69 | const ecrRepositoryStack = new ECRRepositoryStack(this, 'ECRRepositoryStack', this.stageName); 70 | 71 | this.ecrRepositoryOutput = ecrRepositoryStack.repositoryOutput; 72 | this.ecrRepository = ecrRepositoryStack.repository; 73 | 74 | // Only report rootfs image scans in prod to avoid duplicate notifications. 75 | if (props.environmentStage == ENVIRONMENT_STAGE.Prod) { 76 | new EventBridgeScanNotifsStack(this, 'EventBridgeScanNotifsStack', this.stageName); 77 | } 78 | 79 | new ContinuousIntegrationStack(this, 'FinchContinuousIntegrationStack', this.stageName, { 80 | rootfsEcrRepository: this.ecrRepository 81 | }); 82 | } 83 | 84 | new PVREReportingStack(this, 'PVREReportingStack', { terminationProtection: true }); 85 | 86 | new SSMPatchingStack(this, 'SSMPatchingStack', { terminationProtection: true }); 87 | 88 | // Create Ubuntu Codebuild projects for each arch 89 | // CodeBuild credentials are account-wide, so creating them multiple times within the for 90 | // loop causes an error. 91 | // TODO: refactor CodeBuildStack into CodeBuildProjects and loop inside of the constructor. 92 | const codebuildCredsStack = new (class CodeBuildCredentialsStack extends cdk.Stack { 93 | constructor(scope: Construct, id: string) { 94 | super(scope, id, { 95 | ...props, 96 | terminationProtection: true, 97 | }); 98 | new codebuild.GitHubSourceCredentials(this, `code-build-credentials`, { 99 | accessToken: cdk.SecretValue.secretsManager('codebuild-github-access-token') 100 | }); 101 | } 102 | })(this, 'CodeBuildStack-credentials'); 103 | 104 | for (const { arch, operatingSystem, amiSearchString, environmentType, buildImageOS } of CODEBUILD_STACKS) { 105 | const codeBuildStack = new CodeBuildStack(this, `CodeBuildStack-${operatingSystem}-${toStackName(arch)}`, { 106 | env: props.env, 107 | projectName: `finch-${arch}-${props.environmentStage}-instance`, 108 | region: 'us-west-2', 109 | arch, 110 | amiSearchString, 111 | operatingSystem, 112 | buildImageOS: buildImageOS, 113 | environmentType: environmentType 114 | }); 115 | 116 | codeBuildStack.addDependency(codebuildCredsStack); 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /lib/finch-pipeline-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { BuildSpec } from 'aws-cdk-lib/aws-codebuild'; 3 | import { CodePipeline, CodePipelineSource, ShellStep } from 'aws-cdk-lib/pipelines'; 4 | import { Construct } from 'constructs'; 5 | import { EnvConfig } from '../config/env-config'; 6 | import { RunnerConfig } from '../config/runner-config'; 7 | import { applyTerminationProtectionOnStacks } from './aspects/stack-termination-protection'; 8 | import { ENVIRONMENT_STAGE, FinchPipelineAppStage } from './finch-pipeline-app-stage'; 9 | 10 | export class FinchPipelineStack extends cdk.Stack { 11 | constructor(scope: Construct, id: string, props?: cdk.StackProps) { 12 | super(scope, id, props); 13 | applyTerminationProtectionOnStacks([this]); 14 | 15 | const source = CodePipelineSource.gitHub('runfinch/infrastructure', 'main', { 16 | authentication: cdk.SecretValue.secretsManager('pipeline-github-access-token') 17 | }); 18 | 19 | const pipeline = new CodePipeline(this, 'FinchPipeline', { 20 | pipelineName: 'FinchPipeline', 21 | crossAccountKeys: true, 22 | synth: new ShellStep('Synth', { 23 | input: source, 24 | commands: ['npm ci', 'npm run build', 'npx cdk synth'] 25 | }), 26 | synthCodeBuildDefaults: { 27 | partialBuildSpec: BuildSpec.fromObject({ 28 | phases: { 29 | install: { 30 | 'runtime-versions': { 31 | nodejs: '20' 32 | } 33 | } 34 | } 35 | }) 36 | } 37 | }); 38 | 39 | const betaApp = new FinchPipelineAppStage(this, 'Beta', { 40 | environmentStage: ENVIRONMENT_STAGE.Beta, 41 | env: { 42 | account: EnvConfig.envBeta.account, 43 | region: EnvConfig.envBeta.region 44 | }, 45 | runnerConfig: RunnerConfig.runnerBeta 46 | }); 47 | const betaStage = pipeline.addStage(betaApp); 48 | // add a post step for unit and integration tests 49 | betaStage.addPost( 50 | new ShellStep('Unit and Integration Test', { 51 | input: source, 52 | commands: ['npm install', 'npm run build', 'npm run test', 'npm run integration'], 53 | envFromCfnOutputs: { 54 | CLOUDFRONT_URL: betaApp.artifactBucketCloudfrontUrlOutput 55 | } 56 | }) 57 | ); 58 | // Add stages to a wave to deploy them in parallel. 59 | const wave = pipeline.addWave('wave'); 60 | 61 | const prodApp = new FinchPipelineAppStage(this, 'Production', { 62 | environmentStage: ENVIRONMENT_STAGE.Prod, 63 | env: { 64 | account: EnvConfig.envProd.account, 65 | region: EnvConfig.envProd.region 66 | }, 67 | runnerConfig: RunnerConfig.runnerProd 68 | }); 69 | wave.addStage(prodApp); 70 | 71 | const releaseApp = new FinchPipelineAppStage(this, 'Release', { 72 | environmentStage: ENVIRONMENT_STAGE.Release, 73 | env: { 74 | account: EnvConfig.envRelease.account, 75 | region: EnvConfig.envRelease.region 76 | }, 77 | runnerConfig: RunnerConfig.runnerRelease 78 | }); 79 | wave.addStage(releaseApp); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /lib/image-scanning-notifications-lambda-handler/main.py: -------------------------------------------------------------------------------- 1 | ''' 2 | lambda function to read ECR Image Inpsection events from Amazon EventBridge 3 | and send notifications to Finch team regarding security notifications. 4 | ''' 5 | import boto3 6 | import os 7 | 8 | def build_message(event): 9 | '''build_message reads an {event} from Inspector image scanning and builds 10 | the body of reporting email with vulnerability findings. 11 | 12 | :param EventBridgeEvent event: The EventBridgeEvent containing an Inspector scan finding. 13 | 14 | Schema: https://docs.aws.amazon.com/inspector/latest/user/eventbridge-integration.html#event-finding 15 | ''' 16 | detail = event['detail'] 17 | title = detail['title'] 18 | description = detail['description'] 19 | severity = detail['severity'] 20 | source_url = detail['packageVulnerabilityDetails']['sourceUrl'] 21 | status = detail['status'] 22 | type = detail['type'] 23 | finding_arn = detail['findingArn'] 24 | first_observed_at = detail['firstObservedAt'] 25 | 26 | message = f'''{title} - Severity {severity} 27 | 28 | Severity: {severity} 29 | Type: {type} 30 | Description: {description} 31 | Source URL: {source_url} 32 | 33 | Status: {status} 34 | Observed: {first_observed_at} 35 | 36 | For more info, view the finding via ARN in the AWS Console: {finding_arn} 37 | ''' 38 | 39 | return message 40 | 41 | def send_sns(subject: str, message: str): 42 | '''send_sns sends an email with subject and body 43 | 44 | :param str subject: The subject of the email 45 | :param str message: The body of the email 46 | ''' 47 | client = boto3.client("sns") 48 | topic_arn = os.environ["SNS_ARN"] 49 | client.publish(TopicArn=topic_arn, Message=message, Subject=subject) 50 | 51 | def lambda_handler(event, context) -> dict: 52 | '''lambda_handler handles EventBridge events, calling send_sns to send an email for security findings. 53 | 54 | :param EventBridgeEvent event: the EventBridge event 55 | :param LambdaContext context: the Lambda execution context 56 | ''' 57 | detailType = event["detail-type"] 58 | 59 | if (detailType == "Inspector2 Finding"): 60 | subject = "Rootfs Image Security Finding" 61 | message = build_message(event) 62 | send_sns(subject, message) 63 | else: 64 | print("No findings found, skipping sending email") 65 | 66 | return {'statusCode': 200} 67 | -------------------------------------------------------------------------------- /lib/pvre-reporting-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import * as cfninc from 'aws-cdk-lib/cloudformation-include'; 3 | import { Construct } from 'constructs'; 4 | import { applyTerminationProtectionOnStacks } from './aspects/stack-termination-protection'; 5 | 6 | export class PVREReportingStack extends cdk.Stack { 7 | constructor(scope: Construct, id: string, props?: cdk.StackProps) { 8 | super(scope, id, props); 9 | applyTerminationProtectionOnStacks([this]); 10 | 11 | new cfninc.CfnInclude(this, 'PVREReportingTemplate', { 12 | templateFile: 'lib/pvre-reporting-template.yml' 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/pvre-reporting-template.yml: -------------------------------------------------------------------------------- 1 | Resources: 2 | InventoryCollection: 3 | Type: "AWS::SSM::Association" 4 | Properties: 5 | Name: {Ref: PvreInventoryCollectionDocument} 6 | AssociationName: 7 | Fn::Sub: "${AWS::AccountId}-InventoryCollection" 8 | ScheduleExpression: "rate(12 hours)" 9 | Targets: 10 | - Key: "tag:PVRE-Reporting" 11 | Values: 12 | - "SSM" 13 | PvreReporting: 14 | Type: "AWS::SSM::ResourceDataSync" 15 | Properties: 16 | BucketName: 17 | Fn::Sub: "pvrev2-prod-${AWS::Region}-ssm-updates" 18 | BucketRegion: 19 | Ref: "AWS::Region" 20 | SyncFormat: "JsonSerDe" 21 | SyncName: 22 | Fn::Sub: "${AWS::AccountId}-PvreReporting" 23 | PvreInventoryCollectionDocument: 24 | Type: "AWS::SSM::Document" 25 | Properties: 26 | Name: 27 | Fn::Sub: "PVREInventoryCollectionDocument" 28 | DocumentType: "Command" 29 | Content: 30 | schemaVersion: "2.2" 31 | description: "Collect software inventory and kernel information" 32 | parameters: 33 | applications: 34 | type: "String" 35 | default: "Enabled" 36 | description: "(Optional) Collect data for installed applications." 37 | allowedValues: 38 | - "Enabled" 39 | - "Disabled" 40 | awsComponents: 41 | type: "String" 42 | default: "Enabled" 43 | description: "(Optional) Collect data for AWS Components like amazon-ssm-agent." 44 | allowedValues: 45 | - "Enabled" 46 | - "Disabled" 47 | files: 48 | type: "String" 49 | default: "" 50 | description: "

(Optional, requires SSMAgent version 2.2.64.0 and above)

Linux example:
[{\"Path\":\"/usr/bin\", \"Pattern\":[\"aws*\", \"*ssm*\"],\"Recursive\":false},{\"Path\":\"/var/log\", \"Pattern\":[\"amazon*.*\"], \"Recursive\":true, \"DirScanLimit\":1000}]

Windows example:
[{\"Path\":\"%PROGRAMFILES%\", \"Pattern\":[\"*.exe\"],\"Recursive\":true}]

Learn More: http://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-inventory-about.html#sysman-inventory-file-and-registry

" 51 | displayType: "textarea" 52 | networkConfig: 53 | type: "String" 54 | default: "Enabled" 55 | description: "(Optional) Collect data for Network configurations." 56 | allowedValues: 57 | - "Enabled" 58 | - "Disabled" 59 | windowsUpdates: 60 | type: "String" 61 | default: "Enabled" 62 | description: "(Optional, Windows OS only) Collect data for all Windows Updates." 63 | allowedValues: 64 | - "Enabled" 65 | - "Disabled" 66 | instanceDetailedInformation: 67 | type: "String" 68 | default: "Enabled" 69 | description: "(Optional) Collect additional information about the instance, including the CPU model, speed, and the number of cores, to name a few." 70 | allowedValues: 71 | - "Enabled" 72 | - "Disabled" 73 | services: 74 | type: "String" 75 | default: "Enabled" 76 | description: "(Optional, Windows OS only, requires SSMAgent version 2.2.64.0 and above) Collect data for service configurations." 77 | allowedValues: 78 | - "Enabled" 79 | - "Disabled" 80 | windowsRegistry: 81 | type: "String" 82 | default: "" 83 | description: "

(Optional, Windows OS only, requires SSMAgent version 2.2.64.0 and above)

Example:
[{\"Path\":\"HKEY_CURRENT_CONFIG\\System\",\"Recursive\":true},{\"Path\":\"HKEY_LOCAL_MACHINE\\SOFTWARE\\Amazon\\MachineImage\", \"ValueNames\":[\"AMIName\"]}]

Learn More: http://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-inventory-about.html#sysman-inventory-file-and-registry

" 84 | displayType: "textarea" 85 | windowsRoles: 86 | type: "String" 87 | default: "Enabled" 88 | description: "(Optional, Windows OS only, requires SSMAgent version 2.2.64.0 and above) Collect data for Microsoft Windows role configurations." 89 | allowedValues: 90 | - "Enabled" 91 | - "Disabled" 92 | customInventory: 93 | type: "String" 94 | default: "Enabled" 95 | description: "(Optional) Collect data for custom inventory." 96 | allowedValues: 97 | - "Enabled" 98 | - "Disabled" 99 | billingInfo: 100 | type: "String" 101 | default: "Enabled" 102 | description: "(Optional) Collect billing info for license included applications." 103 | allowedValues: 104 | - "Enabled" 105 | - "Disabled" 106 | mainSteps: 107 | - action: "aws:runShellScript" 108 | name: "collectCustomInventoryItems" 109 | inputs: 110 | timeoutSeconds: 7200 111 | runCommand: 112 | - "#!/bin/bash" 113 | - "token=$(curl --silent --show-error --retry 3 -X PUT \"http://169.254.169.254/latest/api/token\" -H \"X-aws-ec2-metadata-token-ttl-seconds: 21600\")" 114 | - "instance_id=$(curl --silent --show-error --retry 3 -H \"X-aws-ec2-metadata-token: $token\" http://169.254.169.254/latest/meta-data/instance-id)" 115 | - "kernel_version=$(uname -r)" 116 | - "content=\"{\\\"SchemaVersion\\\": \\\"1.0\\\", \\\"TypeName\\\": \\\"Custom:SystemInfo\\\", \\\"Content\\\": {\\\"KernelVersion\\\": \\\"$kernel_version\\\"}}\"" 117 | - "dir_path=\"/var/lib/amazon/ssm/$instance_id/inventory/custom\"" 118 | - "mkdir -p $dir_path" 119 | - "echo $content > $dir_path/CustomSystemInfo.json" 120 | - action: "aws:softwareInventory" 121 | name: "collectSoftwareInventoryItems" 122 | inputs: 123 | applications: "{{ applications }}" 124 | awsComponents: "{{ awsComponents }}" 125 | networkConfig: "{{ networkConfig }}" 126 | files: "{{ files }}" 127 | services: "{{ services }}" 128 | windowsRoles: "{{ windowsRoles }}" 129 | windowsRegistry: "{{ windowsRegistry}}" 130 | windowsUpdates: "{{ windowsUpdates }}" 131 | instanceDetailedInformation: "{{ instanceDetailedInformation }}" 132 | billingInfo: "{{ billingInfo }}" 133 | customInventory: "{{ customInventory }}" -------------------------------------------------------------------------------- /lib/ssm-patching-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { CfnMaintenanceWindow, CfnMaintenanceWindowTarget, CfnMaintenanceWindowTask } from 'aws-cdk-lib/aws-ssm'; 3 | import { Construct } from 'constructs'; 4 | import { applyTerminationProtectionOnStacks } from './aspects/stack-termination-protection'; 5 | 6 | export class SSMPatchingStack extends cdk.Stack { 7 | constructor(scope: Construct, id: string, props?: cdk.StackProps) { 8 | super(scope, id, props); 9 | applyTerminationProtectionOnStacks([this]); 10 | 11 | const maintenanceWindow = new CfnMaintenanceWindow(this, 'MaintenanceWindow', { 12 | name: `Patching-Window`, 13 | allowUnassociatedTargets: false, 14 | cutoff: 0, 15 | duration: 2, 16 | // Every day at 8 AM UTC 17 | schedule: 'cron(0 8 ? * * *)' 18 | }); 19 | 20 | const maintenanceTarget = new CfnMaintenanceWindowTarget(this, 'MaintenanceWindowTarget', { 21 | name: 'All-Instances-Patch-Target', 22 | windowId: maintenanceWindow.ref, 23 | resourceType: 'INSTANCE', 24 | targets: [ 25 | { 26 | key: 'tag:PVRE-Reporting', 27 | values: ['SSM'] 28 | } 29 | ] 30 | }); 31 | 32 | new CfnMaintenanceWindowTask(this, 'MaintenanceWindowTask', { 33 | taskArn: 'AWS-RunPatchBaseline', 34 | priority: 1, 35 | taskType: 'RUN_COMMAND', 36 | windowId: maintenanceWindow.ref, 37 | name: 'Patch-Task', 38 | targets: [ 39 | { 40 | key: 'WindowTargetIds', 41 | values: [maintenanceTarget.ref] 42 | } 43 | ], 44 | taskInvocationParameters: { 45 | maintenanceWindowRunCommandParameters: { 46 | parameters: { 47 | Operation: ['Install'] 48 | }, 49 | documentVersion: '$LATEST' 50 | } 51 | }, 52 | maxErrors: '100%', 53 | maxConcurrency: '1' 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import * as codebuild from 'aws-cdk-lib/aws-codebuild'; 2 | 3 | export enum BuildImageOS { 4 | LINUX = 'linux', 5 | WINDOWS = 'windows', 6 | MAC = 'mac' 7 | } 8 | 9 | export interface CodeBuildStackArgs { 10 | operatingSystem: string; 11 | arch: string; 12 | amiSearchString: string; 13 | environmentType: codebuild.EnvironmentType; 14 | buildImageOS: BuildImageOS; 15 | } 16 | 17 | export const CODEBUILD_STACKS: CodeBuildStackArgs[] = [ 18 | { 19 | operatingSystem: 'ubuntu', 20 | arch: 'x86_64', 21 | amiSearchString: 'ubuntu/images/hvm-ssd/ubuntu*22.04*', 22 | environmentType: codebuild.EnvironmentType.LINUX_EC2, 23 | buildImageOS: BuildImageOS.LINUX 24 | }, 25 | { 26 | operatingSystem: 'ubuntu', 27 | arch: 'arm64', 28 | amiSearchString: 'ubuntu/images/hvm-ssd/ubuntu*22.04*', 29 | environmentType: codebuild.EnvironmentType.ARM_EC2, 30 | buildImageOS: BuildImageOS.LINUX 31 | } 32 | ]; 33 | 34 | // members of the runfinch org (+dependabot) 35 | // curl -s https://api.github.com/users/ | jq '.id' 36 | // TODO: automate fetching account ID's 37 | export const GITHUB_ALLOWLISTED_ACCOUNT_IDS = [ 38 | '47769978', // coderbirju 39 | '424987', // pendo324 40 | '55906459', // austinvazquez 41 | '2304727', // Kern-- 42 | '2967759', // henry118 43 | '47723536', // Shubhranshu153 44 | '55555210', // sondavidb 45 | '5525370', // swagatbora90 46 | '59450965', // cezar-r 47 | '49699333' // dependabot[bot] github app 48 | ]; 49 | 50 | /** 51 | * Replaces all underscores with hyphens in a string. 52 | * Used for making strings compatible with stack names, which don't allow "_" in the name. 53 | * 54 | * @param name The string to process 55 | * @returns A new string with all underscores replaced by hyphens 56 | */ 57 | export const toStackName = (name: string) => { 58 | return name.replace(/_/g, '-'); 59 | }; 60 | 61 | // @ts-expect-error Extending private class 62 | export class LinuxAMIBuildImage extends codebuild.LinuxBuildImage implements codebuild.IBuildImage { 63 | declare type: codebuild.EnvironmentType; 64 | 65 | constructor(imageId: string, environmentType: codebuild.EnvironmentType) { 66 | // @ts-expect-error Extending private class 67 | super({ imageId }); 68 | this.type = environmentType; 69 | } 70 | } 71 | 72 | // @ts-expect-error Extending private class 73 | export class WindowsAMIBuildImage extends codebuild.WindowsBuildImage implements codebuild.IBuildImage { 74 | declare type: codebuild.EnvironmentType; 75 | 76 | constructor(imageId: string, environmentType: codebuild.EnvironmentType) { 77 | // @ts-expect-error Extending private class 78 | super({ imageId }); 79 | this.type = environmentType; 80 | } 81 | } 82 | 83 | // @ts-expect-error Extending private class 84 | export class MacAMIBuildImage extends codebuild.LinuxBuildImage implements codebuild.IBuildImage { 85 | declare type: codebuild.EnvironmentType; 86 | 87 | constructor(imageId: string, environmentType: codebuild.EnvironmentType) { 88 | // @ts-expect-error Extending private class 89 | super({ imageId }); 90 | this.type = environmentType; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "finch-aws-cdk", 3 | "version": "0.1.0", 4 | "bin": { 5 | "finch-aws-cdk": "bin/finch-aws-cdk.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest -c ./jest.config.unit.js", 11 | "integration": "jest -c ./jest.config.integration.js", 12 | "cdk": "cdk", 13 | "prettier-format": "prettier --config .prettierrc --write 'lib/*.ts' 'bin/*.ts' 'test/*.ts' 'integration_test/*.ts' 'config/*.ts'" 14 | }, 15 | "devDependencies": { 16 | "@tsconfig/node18": "^18.2.4", 17 | "@types/jest": "^29.5.14", 18 | "@types/node": "^22.15.30", 19 | "@types/prettier": "3.0.0", 20 | "aws-cdk": "^2.1018.1", 21 | "jest": "^29.7.0", 22 | "prettier": "^3.5.3", 23 | "ts-jest": "^29.4.0", 24 | "ts-node": "^10.9.2", 25 | "typescript": "^5.8.3" 26 | }, 27 | "dependencies": { 28 | "aws-cdk-lib": "^2.201.0", 29 | "axios": "^1.10.0", 30 | "constructs": "^10.4.2", 31 | "source-map-support": "^0.5.21" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /scripts/setup-linux-runner.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | set -ex 3 | 4 | # The user data script is run as root so do not use sudo command 5 | 6 | # Log output 7 | exec &> >(tee /var/log/setup-runner.log) 8 | echo $0 9 | 10 | # load all variables from /etc/os-release prefixed with OS_RELEASE as to not clobber 11 | OS_RELEASE_PREFIX="OS_RELEASE" 12 | source <(sed -Ee "s/^([^#])/${OS_RELEASE_PREFIX}_\1/" "/etc/os-release") 13 | 14 | # set variables to make accessing values in /etc/os-release easier 15 | eval "OS_NAME=\"\${${OS_RELEASE_PREFIX}_NAME}\"" 16 | eval "OS_VERSION=\"\${${OS_RELEASE_PREFIX}_VERSION_ID}\"" 17 | 18 | # configure download parameters based on architecture 19 | UNAME_MACHINE="$(/usr/bin/uname -m)" 20 | if [ "${UNAME_MACHINE}" = "aarch64" ]; then 21 | GH_RUNNER_ARCH="arm64" 22 | NODE_DOWNLOAD_ARCH="arm64" 23 | GH_RUNNER_DOWNLOAD_HASH="a96b0cec7b0237ca5e4210982368c6f7d8c2ab1e5f6b2604c1ccede9cedcb143" 24 | else 25 | GH_RUNNER_ARCH="x64" 26 | NODE_DOWNLOAD_ARCH="x86_64" 27 | GH_RUNNER_DOWNLOAD_HASH="b13b784808359f31bc79b08a191f5f83757852957dd8fe3dbfcc38202ccf5768" 28 | fi 29 | 30 | if [ "${OS_NAME}" = "Amazon Linux" ]; then 31 | USERNAME="ec2-user" 32 | DISTRO="amazonlinux" 33 | BASE_PACKAGES="golang zlib-static containerd nerdctl cni-plugins iptables" 34 | if [ "${OS_VERSION}" = "2" ]; then 35 | GH_RUNNER_DEPENDENCIES="openssl krb5-libs zlib jq" 36 | ADDITIONAL_PACKAGES="policycoreutils-python systemd-rpm-macros inotify-tools ${GH_RUNNER_DEPENDENCIES}" 37 | NODE_VERSION="21.2.0" 38 | curl -OL "https://d3rnber7ry90et.cloudfront.net/linux-${NODE_DOWNLOAD_ARCH}/node-v${NODE_VERSION}.tar.gz" 39 | tar -xf node-v${NODE_VERSION}.tar.gz 40 | mv node-v${NODE_VERSION}/bin/* /usr/bin 41 | # enable EPEL repos, required to install inotify-tools package 42 | amazon-linux-extras install epel -y 43 | elif [ "${OS_VERSION}" = "2023" ]; then 44 | GH_RUNNER_DEPENDENCIES="lttng-ust openssl-libs krb5-libs zlib libicu" 45 | ADDITIONAL_PACKAGES="policycoreutils-python-utils ${GH_RUNNER_DEPENDENCIES}" 46 | fi 47 | fi 48 | 49 | HOMEDIR="/home/${USERNAME}" 50 | RUNNER_DIR="${HOMEDIR}/ar" 51 | mkdir -p "${RUNNER_DIR}" && cd "${HOMEDIR}" 52 | 53 | # TODO: add check for non-Fedora based systems if needed 54 | yum upgrade -y 55 | yum group install -y "Development Tools" 56 | # build dependencies for packages 57 | # this sometimes fails on Amazon Linux 2023, so retry if necessary 58 | for i in {1..2}; do 59 | yum install -y ${BASE_PACKAGES} ${ADDITIONAL_PACKAGES} && break || sleep 5 60 | done 61 | 62 | # start containerd 63 | systemctl enable --now containerd 64 | 65 | GH_RUNNER_VERSION="2.322.0" 66 | GH_RUNNER_FILENAME="actions-runner-linux-${GH_RUNNER_ARCH}-${GH_RUNNER_VERSION}.tar.gz" 67 | GH_RUNNER_DOWNLOAD_URL="https://github.com/actions/runner/releases/download/v${GH_RUNNER_VERSION}/${GH_RUNNER_FILENAME}" 68 | 69 | curl -OL "${GH_RUNNER_DOWNLOAD_URL}" 70 | echo "${GH_RUNNER_DOWNLOAD_HASH} ${GH_RUNNER_FILENAME}" | sha256sum -c 71 | tar -C "${RUNNER_DIR}" -xzf "./${GH_RUNNER_FILENAME}" 72 | chown -R "${USERNAME}:${USERNAME}" "${RUNNER_DIR}" 73 | rm "${GH_RUNNER_FILENAME}" 74 | 75 | # TODO: install SSM agent on non-AL hosts if needed 76 | 77 | # Get GH API key and fetch a runner registration token 78 | GH_KEY=$(aws secretsmanager get-secret-value --secret-id $REPO-runner-reg-key --region $REGION | jq '.SecretString' -r) 79 | RUNNER_REG_TOKEN=$(curl -L -s \ 80 | -X POST \ 81 | -H "Accept: application/vnd.github+json" \ 82 | -H "Authorization: Bearer $GH_KEY" \ 83 | -H "X-GitHub-Api-Version: 2022-11-28" \ 84 | "https://api.github.com/repos/runfinch/${REPO}/actions/runners/registration-token" | jq -r '.token') 85 | 86 | if [ -z ${GH_RUNNER_DEPENDENCIES+x} ]; then 87 | echo "Executing installdependencies.sh because GH_RUNNER_DEPENDENCIES is not defined." 88 | "${RUNNER_DIR}/bin/installdependencies.sh" 89 | fi 90 | 91 | # Patch runsvc.sh to use previously downloaded version of Node unless its already patched. 92 | # This needs to be run as part of the systemd service since the runner can auto-update 93 | # and we want to patch every time there's an update, not just the initial installation. 94 | if [ "${OS_NAME}" = "Amazon Linux" ] && [ "${OS_VERSION}" = "2" ]; then 95 | RUNNER_SERVICE_NAME="actions.runner.runfinch-finch.$(hostname | cut -d. -f1).service" 96 | RUNNER_SERVICE_DROPIN_DIR="/etc/systemd/system/${RUNNER_SERVICE_NAME}.d" 97 | RUNNER_SERVICE_DROPIN_FILE="/etc/systemd/system/${RUNNER_SERVICE_NAME}.d/replace-node.conf" 98 | 99 | RUNNER_PATCH_SCRIPT="${HOMEDIR}/replace-node.sh" 100 | RUNSVC_PATH="${RUNNER_DIR}/runsvc.sh" 101 | ORIGINAL_NODE_PATH="./externals/\\\$nodever/bin/node" 102 | SYSTEM_NODE_PATH="/usr/bin/node" 103 | 104 | # Create a systemd service that will fix the node paths whenever a new node version is added. 105 | # This may happen if the actions runner service autoupdates, but does not restart the systemctl service 106 | # (meaning it does not re-trigger the ExecStartPre command which is inserted via our dropin unit). 107 | WATCHER_UNIT_NAME="/etc/systemd/system/fix-actions-runner-node.service" 108 | WATCHER_SCRIPT="${HOMEDIR}/watcher.sh" 109 | 110 | cat > "${RUNNER_PATCH_SCRIPT}" << EOF 111 | #!/usr/bin/bash 112 | if grep -q "${ORIGINAL_NODE_PATH}" "${RUNSVC_PATH}"; then 113 | sed -e "s|${ORIGINAL_NODE_PATH}|${SYSTEM_NODE_PATH}|g" -i "${RUNSVC_PATH}" 114 | fi 115 | 116 | # replace any bundled node binary with a symlink to system node 117 | find "${RUNNER_DIR}" -wholename "${RUNNER_DIR}/externals*/node*/bin/node" | while read line; do 118 | rm -rf \$line 119 | ln -s ${SYSTEM_NODE_PATH} \$line 120 | done 121 | 122 | EOF 123 | 124 | chmod +x "${RUNNER_PATCH_SCRIPT}" 125 | 126 | mkdir -p "${RUNNER_SERVICE_DROPIN_DIR}" 127 | cat > "${RUNNER_SERVICE_DROPIN_FILE}" << EOF 128 | [Service] 129 | ExecStartPre=${RUNNER_PATCH_SCRIPT} 130 | EOF 131 | 132 | # Monitor the $RUNNER_DIR for all file changes. This cannot be scoped down 133 | # because inotifywait does not take a file glob itself, and the externals directory 134 | # can be suffixed with version numbers (e.g. externals.someversion, not just one externals dir). 135 | cat > "${WATCHER_SCRIPT}" << EOF 136 | #!/usr/bin/bash 137 | inotifywait -mr "${RUNNER_DIR}" -e create -e moved_to | 138 | while read -r directory action file; do 139 | path="\${directory}\${file}" 140 | if [[ ! "\$path" =~ .*externals.*\/node.* ]]; then 141 | echo 'no match for file \${path}, skipping' | systemd-cat -t "node-patcher" -p info 142 | continue 143 | fi 144 | if [[ "\$(readlink -e ${SYSTEM_NODE_PATH})" == "\$(readlink -e \$file)" ]]; then 145 | # file is already linked properly, skip 146 | echo "file \${path} already linked properly, skipping" | systemd-cat -t "node-patcher" -p info 147 | continue 148 | fi 149 | 150 | echo 'updating node path, triggered by \${path}' | systemd-cat -t "node-patcher" -p info 151 | 152 | "${RUNNER_PATCH_SCRIPT}" 153 | done 154 | EOF 155 | 156 | chmod +x "${WATCHER_SCRIPT}" 157 | 158 | cat > "${WATCHER_UNIT_NAME}" << EOF 159 | [Service] 160 | ExecStart=${WATCHER_SCRIPT} 161 | 162 | [Unit] 163 | Description=GitHub Actions runner node version watcher 164 | 165 | [Install] 166 | WantedBy=multi-user.target 167 | EOF 168 | 169 | systemctl daemon-reload 170 | systemctl enable --now "${WATCHER_UNIT_NAME}" 171 | fi 172 | 173 | # Configure the runner with the registration token, launch the service 174 | # these commands must NOT be run as root 175 | sudo -i -u "${USERNAME}" bash < /Users/ec2-user/setup-runner.log 4 | 5 | echo $0 6 | 7 | # Set up the environment 8 | HOMEDIR="/Users/ec2-user" 9 | RUNNER_DIR="$HOMEDIR/ar" 10 | mkdir -p $RUNNER_DIR && cd $RUNNER_DIR 11 | 12 | # Configure brew path 13 | # Adding to .zshenv so that it will load even in non-interactive/login shells 14 | UNAME_MACHINE="$(/usr/bin/uname -m)" 15 | if [[ "${UNAME_MACHINE}" == "arm64" ]] 16 | then 17 | HOMEBREW_PREFIX="/opt/homebrew" 18 | else 19 | HOMEBREW_PREFIX="/usr/local" 20 | fi 21 | (echo; echo 'eval "$('"${HOMEBREW_PREFIX}"'/bin/brew shellenv)"') >> $HOMEDIR/.zshenv 22 | 23 | # Setup current shell 24 | PATH="/opt/homebrew/bin:/opt/homebrew/sbin:$PATH" 25 | 26 | # Download and setup the runner 27 | if [ $(arch) == "arm64" ] 28 | then 29 | LABEL_ARCH="arm64" 30 | curl -o actions-runner-osx-arm64-2.303.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.303.0/actions-runner-osx-arm64-2.303.0.tar.gz 31 | echo "bbbac8011066b6cec93deb2365132b082b92287baaf34b5d9539e955ffe450ff actions-runner-osx-arm64-2.303.0.tar.gz" | shasum -a 256 -c 32 | # Extract the installer 33 | tar xzf ./actions-runner-osx-arm64-2.303.0.tar.gz 34 | else 35 | LABEL_ARCH="amd64" 36 | curl -o actions-runner-osx-x64-2.303.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.303.0/actions-runner-osx-x64-2.303.0.tar.gz 37 | echo "8bd595568ceee5eb25576972bc8075b47c149b3fac7eb7873deed67944b45739 actions-runner-osx-x64-2.303.0.tar.gz" | shasum -a 256 -c 38 | # Extract the installer 39 | tar xzf ./actions-runner-osx-x64-2.303.0.tar.gz 40 | fi 41 | 42 | # Get GH API key and fetch a runner registration token 43 | su ec2-user -c 'brew install jq' 44 | GH_KEY=$(su ec2-user -c "aws secretsmanager get-secret-value --secret-id $REPO-runner-reg-key --region $REGION" | su ec2-user -c "jq '.SecretString' -r") 45 | LABEL_VER=$(sw_vers -productVersion | cut -d '.' -f 1) 46 | RUNNER_REG_TOKEN=$(curl -L -s \ 47 | -X POST \ 48 | -H "Accept: application/vnd.github+json" \ 49 | -H "Authorization: Bearer $GH_KEY"\ 50 | -H "X-GitHub-Api-Version: 2022-11-28" \ 51 | https://api.github.com/repos/runfinch/$REPO/actions/runners/registration-token | su ec2-user -c "jq -r '.token'") 52 | 53 | # Configure the runner with the registration token, launch the service 54 | su - ec2-user -c "cd $RUNNER_DIR && ./config.sh --url https://github.com/runfinch/$REPO --unattended --token $RUNNER_REG_TOKEN --work _work --labels '$LABEL_ARCH,$LABEL_VER,$LABEL_STAGE'" 55 | su - ec2-user -c "cd $RUNNER_DIR && ./svc.sh install" 56 | PLIST_NAME="actions.runner.runfinch-$REPO.$(hostname | cut -d '.' -f 1).plist" 57 | cp /Users/ec2-user/Library/LaunchAgents/$PLIST_NAME /Library/LaunchDaemons 58 | /bin/launchctl load /Library/LaunchDaemons/$PLIST_NAME 59 | -------------------------------------------------------------------------------- /scripts/windows-runner-user-data.yaml: -------------------------------------------------------------------------------- 1 | # windows-runner-user-data.yaml 2 | # 3 | # User Script for launching a Windows EC2 instance that: 4 | # - logs to C:\UserData.log 5 | # - installs git, Make, AWS tools, latest powershell, Nuget, yq and Carbon 6 | # - installs WSL2 and its dependencies 7 | # * reboots the instance to apply the installation 8 | # - periodically updates WSL2 9 | # - registers as a self-hosted GitHub Actions runner on instance startup 10 | version: 1.0 11 | tasks: 12 | - task: executeScript 13 | inputs: 14 | - frequency: once 15 | type: powershell 16 | runAs: admin 17 | content: |- 18 | Start-Transcript -Path "C:\UserData.log" -Append 19 | $progressPreference = 'silentlyContinue' 20 | $RUNNER_DIR="C:\actions-runner" 21 | 22 | # "" values set in lib/asg-runner-stack.ts 23 | $LABEL_ARCH="amd64" 24 | $LABEL_STAGE="" 25 | $REPO="" 26 | $REGION="" 27 | 28 | Write-Information "Installing latest powershell7 version..." 29 | Invoke-Expression "& { $(Invoke-RestMethod 'https://aka.ms/install-powershell.ps1') } -useMSI -EnablePSRemoting -Quiet" 30 | 31 | New-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell -Value "C:\Program Files\PowerShell\7\pwsh.exe" -PropertyType String -Force 32 | 33 | New-Item -Path $Profile -ItemType File -Force 34 | 'Set-PSReadLineOption -EditMode Emacs' | Out-File -Append $Profile 35 | 36 | New-Item -Path $Home\setup -ItemType Directory 37 | New-Item -Path $Home\setup\bin -ItemType Directory 38 | Set-Location $Home\setup 39 | 40 | Write-Information "Install dependencies git, make, go, AWS tools, NuGet and update path..." 41 | 42 | # Install latest NuGet so that Carbon installation works later 43 | Install-PackageProvider -Name NuGet -Force 44 | 45 | $pws7script = @' 46 | Start-Transcript -Path "$Home\setup\setup7.log" -Append 47 | Import-Module -Name Appx -UseWindowsPowerShell 48 | 49 | # Install git and Make 50 | Invoke-WebRequest -Uri 'https://github.com/git-for-windows/git/releases/download/v2.47.0.windows.2/Git-2.47.0.2-64-bit.exe' -OutFile 'git-2.47.0.2.exe' 51 | .\git-2.47.0.2.exe /SILENT 52 | # use curl to follow redirects 53 | & "C:\Windows\System32\curl.exe" -L https://sourceforge.net/projects/gnuwin32/files/make/3.81/make-3.81.exe/download -o make.exe 54 | .\make.exe /SILENT 55 | 56 | # Install AWS tools 57 | Install-Module -Name AWS.Tools.Common -Force 58 | Install-Module -Name AWS.Tools.SecretsManager -Force 59 | Install-Module -Name AWS.Tools.EC2 -Force 60 | Install-Module -Name AWS.Tools.AutoScaling -Force 61 | 62 | # Install AWS CLI 63 | Invoke-WebRequest -Uri https://awscli.amazonaws.com/AWSCLIV2.msi -OutFile AWSCLIV2.msi 64 | Start-Process msiexec.exe -Wait -ArgumentList '/I AWSCLIV2.msi /quiet' 65 | Remove-Item AWSCLIV2.msi 66 | 67 | # Install Go 68 | Invoke-WebRequest -Uri 'https://go.dev/dl/go1.23.3.windows-amd64.msi' -OutFile 'go1.23.3.windows-amd64.msi' 69 | Start-Process msiexec.exe -Wait -ArgumentList '/I C:\Users\Administrator\setup\go1.23.3.windows-amd64.msi /quiet' 70 | 71 | # Install yq 72 | Invoke-WebRequest -Uri 'https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_windows_amd64.exe' -OutFile '.\bin\yq.exe' 73 | 74 | # Configure path; include path to pre release WSL 75 | $newPath = ("C:\Program Files\Git\bin\;" + "C:\Program Files\Git\usr\bin\;" + "C:\Program Files\WSL\;" + "$env:Path" + ";C:\Program Files\Git\bin\;" + "C:\Program Files (x86)\GnuWin32\bin\;" + "C:\Program Files\Go\bin\;" + "$Home\setup\bin\;" + "C:\Program Files\Amazon\AWSCLIV2\;") 76 | $env:Path = $newPath 77 | # Persist the path to the registry for new shells 78 | Set-ItemProperty -Path 'Registry::HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager\Environment' -Name PATH -Value $newPath 79 | '@ 80 | 81 | # Write PowerShell 7 script to file 82 | $pws7script | Out-File $Home\setup\setup7.ps1 83 | 84 | # Execute script with PowerShell 7 85 | Write-Information "Running setup7.ps1..." 86 | $ConsoleCommand = "$Home\setup\setup7.ps1" 87 | Start-Process "C:\Program Files\PowerShell\7\pwsh" -Wait -NoNewWindow -PassThru -ArgumentList "-Command &{ $ConsoleCommand }" 88 | 89 | Write-Information "Downloading and configuring GitHub Actions Runner..." 90 | mkdir $RUNNER_DIR; cd $RUNNER_DIR 91 | Invoke-WebRequest -Uri https://github.com/actions/runner/releases/download/v2.316.0/actions-runner-win-x64-2.316.0.zip -OutFile actions-runner-win-x64-2.316.0.zip 92 | if((Get-FileHash -Path actions-runner-win-x64-2.316.0.zip -Algorithm SHA256).Hash.ToUpper() -ne '9b2d0443d11ce5c2c4391d708576dc37b1ecf62edcceec7c0c9c8e6b4472b5a1'.ToUpper()){ throw 'Computed checksum did not match' } 93 | Add-Type -AssemblyName System.IO.Compression.FileSystem ; [System.IO.Compression.ZipFile]::ExtractToDirectory("$PWD/actions-runner-win-x64-2.316.0.zip", "$PWD") 94 | 95 | $GH_KEY=(Get-SECSecretValue -SecretId $REPO-runner-reg-key -Region $REGION).SecretString 96 | $LABEL_OS_VER=$([System.Environment]::OSVersion.Version.Major) 97 | $LABEL_BUILD_VER=$([System.Environment]::OSVersion.Version.Build) 98 | 99 | $RUNNER_REG_TOKEN=((Invoke-WebRequest -UseBasicParsing -Method POST -Headers @{"Accept" = "application/vnd.github+json"; "Authorization" = "Bearer $GH_KEY"; "X-GitHub-Api-Version" = "2022-11-28"} -Uri https://api.github.com/repos/runfinch/$REPO/actions/runners/registration-token).Content | ConvertFrom-Json).token 100 | 101 | Write-Information "Starting GitHub Actions Runner..." 102 | cd $RUNNER_DIR; ./config.cmd --url https://github.com/runfinch/$REPO --unattended --runasservice --token $RUNNER_REG_TOKEN --work _work --labels $LABEL_ARCH,$LABEL_OS_VER,$LABEL_BUILD_VER,$LABEL_STAGE 103 | 104 | # To install WSL2, the instance must be rebooted: https://learn.microsoft.com/en-us/windows/wsl/install 105 | # To accomplish this while not replacing the instance in the autoscaling group, 106 | # 107 | # 1. Put the instance in "StandBy" mode 108 | # 2. Register a powershell script to be run at instance reboot on login 109 | # 3. Install WSL2 and restart the instance 110 | 111 | Write-Information "Entering StandBy..." 112 | $InstanceId=(Get-EC2InstanceMetadata -Category InstanceId) 113 | $ASGName=(Get-ASAutoScalingInstance -InstanceId $InstanceId).AutoScalingGroupName 114 | Enter-ASStandby -AutoScalingGroupName $ASGName -InstanceId $InstanceId -ShouldDecrementDesiredCapacity $true 115 | 116 | # Script runs on login after reboot to start GitHub Actions service, put the instance InService in the ASG, 117 | # and update wsl version to pre-release verion such that ssh / session 0 connections can call wsl 118 | # Allow inbound connections on vEthernet interface for traffic from wsl to host 119 | $ServiceName=(Get-Service actions.runner.*).name 120 | $startupscript = @' 121 | Start-Transcript -Path "C:\StartupScript.log" -Append 122 | Invoke-WebRequest -Uri 'https://github.com/microsoft/WSL/releases/download/2.4.13/wsl.2.4.13.0.x64.msi' -OutFile 'C:\Users\Administrator\setup\wsl.2.4.13.0.x64.msi' 123 | Start-Process msiexec.exe -Wait -ArgumentList '/i C:\Users\Administrator\setup\wsl.2.4.13.0.x64.msi /L*V C:\WSLInstallation.log /quiet' 124 | # Update the WSL kernel 125 | Invoke-WebRequest -Uri 'https://wslstorestorage.blob.core.windows.net/wslblob/wsl_update_x64.msi' -OutFile 'C:\Users\Administrator\setup\wsl_update_x64.msi' 126 | Start-Process msiexec.exe -Wait -ArgumentList '/i C:\Users\Administrator\setup\wsl_update_x64.msi /L*V C:\WSLInstallation.log /quiet' 127 | Exit-ASStandby -AutoScalingGroupName $ASGName -InstanceId $InstanceId 128 | Add-MpPreference -ExclusionPath "C:\Users\ADMINI~1\AppData\Local\Temp\go-build*" -Force 129 | Start-Job -ScriptBlock { Start-Process -NoNewWindow -FilePath wsl -ArgumentList '--install Ubuntu' } 130 | sleep 30 # sleep to allow Ubuntu VM to start 131 | New-NetFirewallRule -DisplayName "WSL" -Direction Inbound -InterfaceAlias "vEthernet (WSL)" -Action Allow 132 | '@ 133 | 134 | # Write startup powershell script to file. 135 | # $ExecutionContext.InvokeCommand.ExpandString will evaluate variables first, so the ASG and instance ID are specified 136 | Set-Content 'C:\startup.ps1' ($ExecutionContext.InvokeCommand.ExpandString($startupscript)) 137 | 138 | # Register a scheduled job to run the script to regist the instance as a runner on boot. 139 | $trigger = New-JobTrigger -AtStartup -RandomDelay 00:00:30 140 | Register-ScheduledJob -Trigger $trigger -FilePath C:\startup.ps1 -Name startup-job-inservice 141 | 142 | Write-Information "Changing GHA service ownership to Administrator..." 143 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 144 | Install-Module -Name 'Carbon' -AllowClobber -Force 145 | # Configure the GitHub Actions runner service to log on as Administrator; grant the Administrator 146 | # user "Log on as a Service" privileges via Carbon module: 147 | # https://get-carbon.org/Grant-Privilege.html 148 | Stop-Service "actions.runner.*" 149 | Grant-CPrivilege -Identity "Administrator" "SeServiceLogonRight" 150 | 151 | net user Administrator "$GH_KEY" 152 | 153 | Write-Information "starting GHA service..." 154 | Set-Service -Name $ServiceName -StartupType Automatic 155 | Start-Service "actions.runner.*" 156 | 157 | Invoke-Expression "cmd.exe /c sc config $ServiceName obj= '$(hostname)\Administrator' password= '$GH_KEY' type= own" 158 | 159 | wsl --install 160 | 161 | Write-Information "Restarting instance..." 162 | Restart-Computer 163 | -------------------------------------------------------------------------------- /test/artifact-bucket-cloudfront.test.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Match, Template } from 'aws-cdk-lib/assertions'; 3 | import { ArtifactBucketCloudfrontStack } from '../lib/artifact-bucket-cloudfront'; 4 | 5 | describe('ArtifactBucketCloudfrontStack', () => { 6 | test('synthesizes the way we expect', () => { 7 | const app = new cdk.App(); 8 | const cloudfront = new ArtifactBucketCloudfrontStack(app, 'CloudfrontStack', 'test'); 9 | 10 | // prepare the ArtifactBucketCloudfrontStack template for assertions 11 | const template = Template.fromStack(cloudfront); 12 | 13 | // assert it creates the s3 bucket 14 | template.resourceCountIs('AWS::S3::Bucket', 1); 15 | template.hasResource('AWS::S3::Bucket', { 16 | Properties: { 17 | BucketName: Match.anyValue(), 18 | PublicAccessBlockConfiguration: { 19 | BlockPublicAcls: true, 20 | BlockPublicPolicy: true, 21 | IgnorePublicAcls: true, 22 | RestrictPublicBuckets: true 23 | } 24 | }, 25 | UpdateReplacePolicy: 'Retain', 26 | DeletionPolicy: 'Retain' 27 | }); 28 | 29 | // assert it creates the cloudfront distribution 30 | template.resourceCountIs('AWS::CloudFront::Distribution', 1); 31 | 32 | expect(cloudfront.terminationProtection).toBeTruthy(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/asg-runner-stack.test.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Template } from 'aws-cdk-lib/assertions'; 3 | import { PlatformType, RunnerConfig, RunnerType } from '../config/runner-config'; 4 | import { ASGRunnerStack } from '../lib/asg-runner-stack'; 5 | import { ENVIRONMENT_STAGE } from '../lib/finch-pipeline-app-stage'; 6 | 7 | const generateASGStackName = (runnerType: RunnerType) => 8 | `ASG-${runnerType.platform}-${runnerType.repo}-${runnerType.version.split('.')[0]}-${runnerType.arch}Stack`; 9 | 10 | describe('ASGRunnerStack test', () => { 11 | const app = new cdk.App(); 12 | const runnerConfig = RunnerConfig.runnerProd; 13 | const stacks: ASGRunnerStack[] = []; 14 | runnerConfig.runnerTypes.forEach((runnerType) => { 15 | const ASGStackName = generateASGStackName(runnerType); 16 | const licenseArn = 17 | runnerType.platform === PlatformType.WINDOWS ? runnerConfig.windowsLicenseArn : runnerConfig.macLicenseArn; 18 | stacks.push( 19 | new ASGRunnerStack(app, ASGStackName, { 20 | env: { 21 | account: '123456789012', 22 | region: 'us-east-1' 23 | }, 24 | stage: ENVIRONMENT_STAGE.Prod, 25 | licenseArn: licenseArn, 26 | type: runnerType 27 | }) 28 | ); 29 | }); 30 | const templates = stacks.map((stack) => Template.fromStack(stack)); 31 | 32 | it('should have the correct number of resources', () => { 33 | templates.forEach((template) => { 34 | template.resourceCountIs('AWS::AutoScaling::AutoScalingGroup', 1); 35 | template.resourceCountIs('AWS::EC2::LaunchTemplate', 1); 36 | template.resourceCountIs('AWS::ResourceGroups::Group', 1); 37 | template.resourceCountIs('AWS::EC2::SecurityGroup', 1); 38 | template.resourceCountIs('AWS::IAM::Role', 1); 39 | template.resourceCountIs('AWS::IAM::Policy', 1); 40 | }); 41 | }); 42 | 43 | it('should match the runner configuration', () => { 44 | expect(stacks.length).toBe(runnerConfig.runnerTypes.length); 45 | runnerConfig.runnerTypes.forEach((type) => { 46 | const stack = stacks.find((stack) => stack.stackName === generateASGStackName(type)); 47 | expect(stack).toBeDefined(); 48 | const template = Template.fromStack(stack!); 49 | let instanceType = ''; 50 | switch (type.platform) { 51 | case PlatformType.WINDOWS: { 52 | instanceType = 'm5zn.metal'; 53 | break; 54 | } 55 | case PlatformType.MAC: { 56 | if (type.arch === 'arm') { 57 | instanceType = 'mac2.metal'; 58 | } else { 59 | instanceType = 'mac1.metal'; 60 | } 61 | break; 62 | } 63 | case PlatformType.AMAZONLINUX: { 64 | if (type.arch === 'arm') { 65 | instanceType = 'c7g.large'; 66 | } else { 67 | instanceType = 'c7a.large'; 68 | } 69 | break; 70 | } 71 | } 72 | template.hasResourceProperties('AWS::EC2::LaunchTemplate', { 73 | LaunchTemplateData: { 74 | InstanceType: instanceType 75 | } 76 | }); 77 | }); 78 | }); 79 | 80 | it('must have termination protection enabled', () => { 81 | stacks.forEach((stack) => { 82 | expect(stack.terminationProtection).toBeTruthy(); 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /test/aspects/stack-termination-protection.test.ts: -------------------------------------------------------------------------------- 1 | // Finch Infrastructure 2 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | import { App, Stack } from "aws-cdk-lib"; 5 | import { Bucket } from "aws-cdk-lib/aws-s3"; 6 | import { applyTerminationProtectionOnStacks } from "../../lib/aspects/stack-termination-protection"; 7 | 8 | describe('Stack termination protection aspect', () => { 9 | it('must enable termination protection when applied to a stack and synthesized', () => { 10 | const app = new App(); 11 | const stack = new Stack(app, 'FooStack'); 12 | // stack needs one resource 13 | new Bucket(stack, 'BarBucket'); 14 | 15 | applyTerminationProtectionOnStacks([stack]); 16 | app.synth(); 17 | 18 | expect(stack.terminationProtection).toBeTruthy(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /test/cloudfront_cdn.test.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Match, Template } from 'aws-cdk-lib/assertions'; 3 | import * as s3 from 'aws-cdk-lib/aws-s3'; 4 | import { CfnBucket } from 'aws-cdk-lib/aws-s3'; 5 | import { CloudfrontCdn } from '../lib/cloudfront_cdn'; 6 | 7 | describe('CloudfrontCdn', () => { 8 | test('synthesizes the way we expect', () => { 9 | const app = new cdk.App(); 10 | 11 | // create a stack for CloudfrontCdn to live in 12 | const cloudfrontCdnStack = new cdk.Stack(app, 'CloudfrontCdnStack'); 13 | const bucket = new s3.Bucket(cloudfrontCdnStack, 'TestBucket', { 14 | bucketName: 'test-bucket', 15 | publicReadAccess: false, 16 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL 17 | }); 18 | 19 | // create the CloudfrontCdn stack for assertions 20 | const cloudfrontCdn = new CloudfrontCdn(cloudfrontCdnStack, 'CloudfrontCdn', { 21 | bucket: bucket 22 | }); 23 | 24 | const template = Template.fromStack(cloudfrontCdnStack); 25 | 26 | // assert it creates the s3 bucket 27 | template.resourceCountIs('AWS::S3::Bucket', 1); 28 | template.hasResource('AWS::S3::Bucket', { 29 | Properties: { 30 | BucketName: 'test-bucket', 31 | PublicAccessBlockConfiguration: { 32 | BlockPublicAcls: true, 33 | BlockPublicPolicy: true, 34 | IgnorePublicAcls: true, 35 | RestrictPublicBuckets: true 36 | } 37 | }, 38 | UpdateReplacePolicy: 'Retain', 39 | DeletionPolicy: 'Retain' 40 | }); 41 | 42 | const bukcetLogicalId = cloudfrontCdnStack.getLogicalId(bucket.node.defaultChild as CfnBucket); 43 | // assert the bucket policy 44 | template.hasResourceProperties('AWS::S3::BucketPolicy', { 45 | Bucket: { 46 | Ref: bukcetLogicalId 47 | }, 48 | PolicyDocument: { 49 | Statement: [ 50 | Match.objectLike({ 51 | Action: 's3:GetObject', 52 | Effect: 'Allow', 53 | Principal: { 54 | CanonicalUser: { 55 | 'Fn::GetAtt': [Match.anyValue(), 'S3CanonicalUserId'] 56 | } 57 | }, 58 | Resource: { 59 | 'Fn::Join': [ 60 | '', 61 | [ 62 | { 63 | 'Fn::GetAtt': [bukcetLogicalId, 'Arn'] 64 | }, 65 | '/*' 66 | ] 67 | ] 68 | } 69 | }) 70 | ], 71 | Version: '2012-10-17' 72 | } 73 | }); 74 | 75 | // assert it creates the cloudfront distribution 76 | template.resourceCountIs('AWS::CloudFront::Distribution', 1); 77 | // the cloufront has a s3 origin and OAI to access the s3 bucket 78 | template.hasResourceProperties('AWS::CloudFront::Distribution', { 79 | DistributionConfig: { 80 | DefaultCacheBehavior: { 81 | AllowedMethods: ['GET', 'HEAD'] 82 | }, 83 | Origins: [ 84 | { 85 | DomainName: { 86 | 'Fn::GetAtt': [Match.anyValue(), 'RegionalDomainName'] 87 | }, 88 | Id: Match.anyValue(), 89 | S3OriginConfig: { 90 | OriginAccessIdentity: { 91 | 'Fn::Join': [ 92 | '', 93 | [ 94 | 'origin-access-identity/cloudfront/', 95 | { 96 | Ref: Match.anyValue() 97 | } 98 | ] 99 | ] 100 | } 101 | } 102 | } 103 | ] 104 | } 105 | }); 106 | 107 | template.hasResourceProperties('AWS::CloudFront::CloudFrontOriginAccessIdentity', { 108 | CloudFrontOriginAccessIdentityConfig: { 109 | Comment: 'OAI for artifact bucket cloudfront' 110 | } 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /test/codebuild-stack.test.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Match, Template } from 'aws-cdk-lib/assertions'; 3 | import * as codebuild from 'aws-cdk-lib/aws-codebuild'; 4 | import { CodeBuildStack } from '../lib/codebuild-stack'; 5 | 6 | import { BuildImageOS, CODEBUILD_STACKS, CodeBuildStackArgs, toStackName } from '../lib/utils'; 7 | 8 | describe('CodeBuildStack', () => { 9 | test('synthesizes the way we expect', () => { 10 | const app = new cdk.App(); 11 | const codebuildStackArgs: CodeBuildStackArgs = { 12 | operatingSystem: 'ubuntu', 13 | arch: 'x86_64', 14 | amiSearchString: 'ubuntu*22.04*', 15 | environmentType: codebuild.EnvironmentType.LINUX_EC2, 16 | buildImageOS: BuildImageOS.LINUX 17 | }; 18 | 19 | const stack = new CodeBuildStack(app, 'TestStack', { 20 | env: { 21 | account: '123456789012', 22 | region: 'us-east-1' 23 | }, 24 | projectName: 'test-project', 25 | region: 'us-west-2', 26 | ...codebuildStackArgs 27 | }); 28 | const template = Template.fromStack(stack); 29 | 30 | validateTemplate(codebuildStackArgs, template); 31 | }); 32 | 33 | test('all codebuild stacks in ./utils synthesize as expected', () => { 34 | for (const codebuildStackArgs of CODEBUILD_STACKS) { 35 | const app = new cdk.App(); 36 | const stack = new CodeBuildStack(app, 'TestStack', { 37 | env: { 38 | account: '123456789012', 39 | region: 'us-east-1' 40 | }, 41 | projectName: 'test-project', 42 | region: 'us-west-2', 43 | ...codebuildStackArgs 44 | }); 45 | const template = Template.fromStack(stack); 46 | 47 | validateTemplate(codebuildStackArgs, template); 48 | } 49 | }); 50 | }); 51 | 52 | const validateTemplate = (codebuildStack: CodeBuildStackArgs, template: Template) => { 53 | // Assert that the stack creates a Fleet 54 | template.hasResourceProperties('AWS::CodeBuild::Project', { 55 | Name: 'test-project', 56 | Environment: { 57 | Type: codebuildStack.environmentType, 58 | ComputeType: 'BUILD_GENERAL1_MEDIUM', 59 | Image: Match.stringLikeRegexp('ami-1234'), 60 | }, 61 | Source: { 62 | Type: 'GITHUB', 63 | Location: 'https://github.com/runfinch/finch.git', 64 | ReportBuildStatus: true 65 | } 66 | }); 67 | 68 | // Assert that the stack creates a Fleet 69 | template.hasResourceProperties('AWS::CodeBuild::Fleet', { 70 | BaseCapacity: 1, 71 | ComputeType: 'BUILD_GENERAL1_MEDIUM', 72 | EnvironmentType: codebuildStack.environmentType 73 | }); 74 | 75 | // Assert that the stack creates a Fleet service role 76 | template.hasResourceProperties('AWS::IAM::Role', { 77 | ManagedPolicyArns: Match.arrayWith([ 78 | { 79 | 'Fn::Join': ['', Match.arrayWith([':iam::aws:policy/AmazonEC2FullAccess'])] 80 | }, 81 | { 82 | 'Fn::Join': ['', Match.arrayWith([':iam::aws:policy/AmazonSSMManagedInstanceCore'])] 83 | } 84 | ]) 85 | }); 86 | 87 | // Assert that the stack creates a KMS key 88 | template.hasResourceProperties('AWS::KMS::Key', { 89 | Description: 'Kms Key to encrypt data-at-rest', 90 | Enabled: true 91 | }); 92 | 93 | // Assert that the stack creates a KMS alias 94 | template.hasResourceProperties('AWS::KMS::Alias', { 95 | AliasName: Match.stringLikeRegexp( 96 | `alias/finch-${codebuildStack.operatingSystem}-${toStackName(codebuildStack.arch)}-kms-us-west-2` 97 | ) 98 | }); 99 | 100 | // Check resource count 101 | template.resourceCountIs('AWS::CodeBuild::Project', 1); 102 | template.resourceCountIs('AWS::CodeBuild::Fleet', 1); 103 | template.resourceCountIs('AWS::KMS::Key', 1); 104 | }; 105 | -------------------------------------------------------------------------------- /test/ecr-repo-stack.test.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Match, Template } from 'aws-cdk-lib/assertions'; 3 | import { ECRRepositoryStack } from '../lib/ecr-repo-stack'; 4 | 5 | describe('ECRRepositoryStack', () => { 6 | test('synthesizes the way we expect', () => { 7 | const app = new cdk.App(); 8 | const ecrRepo = new ECRRepositoryStack(app, 'ECRRepositoryStack', 'test'); 9 | 10 | // prepare the ECRRepositoryStack template for assertions 11 | const template = Template.fromStack(ecrRepo); 12 | 13 | // assert it creates the ecr repo with properties set. 14 | template.resourceCountIs('AWS::ECR::Repository', 1); 15 | template.hasResource('AWS::ECR::Repository', { 16 | Properties: { 17 | RepositoryName: Match.anyValue(), 18 | ImageTagMutability: "IMMUTABLE", 19 | ImageScanningConfiguration: { 20 | ScanOnPush: true, 21 | }, 22 | }, 23 | }); 24 | 25 | expect(ecrRepo.terminationProtection).toBeTruthy(); 26 | }); 27 | }) 28 | -------------------------------------------------------------------------------- /test/event-bridge-scan-notifs-stack.test.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Match, Template } from 'aws-cdk-lib/assertions'; 3 | import { EventBridgeScanNotifsStack } from '../lib/event-bridge-scan-notifs-stack'; 4 | 5 | describe('EventBridgeScanNotifsStack', () => { 6 | test('synthesizes the way we expect', () => { 7 | const app = new cdk.App(); 8 | const eventBridgeStack = new EventBridgeScanNotifsStack(app, 'EventBridgeScanNotifsStack', 'test'); 9 | 10 | const template = Template.fromStack(eventBridgeStack); 11 | 12 | template.resourceCountIs('AWS::Lambda::Function', 1); 13 | template.hasResource('AWS::Lambda::Function', { 14 | Properties: { 15 | Environment:{ 16 | Variables: { 17 | "SNS_ARN": Match.anyValue() 18 | } 19 | }, 20 | Runtime: "python3.11", 21 | }, 22 | }); 23 | 24 | const lambda = template.findResources('AWS::Lambda::Function') 25 | const lambdaLogicalID = Object.keys(lambda)[0] 26 | 27 | template.resourceCountIs('AWS::SNS::Topic', 1); 28 | 29 | template.resourceCountIs('AWS::Events::Rule', 1); 30 | template.hasResource('AWS::Events::Rule', { 31 | Properties: { 32 | EventPattern: { 33 | source: ["aws.inspector2"] 34 | }, 35 | State: "ENABLED", 36 | Targets: [ 37 | { 38 | "Arn":{ 39 | "Fn::GetAtt": [ 40 | lambdaLogicalID, 41 | "Arn" 42 | ] 43 | } 44 | } 45 | ], 46 | } 47 | }); 48 | 49 | expect(eventBridgeStack.terminationProtection).toBeTruthy(); 50 | }); 51 | }) 52 | -------------------------------------------------------------------------------- /test/finch-pipeline-stack.test.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Template } from 'aws-cdk-lib/assertions'; 3 | import { EnvConfig } from '../config/env-config'; 4 | import { FinchPipelineStack } from '../lib/finch-pipeline-stack'; 5 | 6 | describe('FinchPipelineStack', () => { 7 | test('synthesizes the way we expect', () => { 8 | const app = new cdk.App(); 9 | const stack = new FinchPipelineStack(app, 'TestPipelineStack', { 10 | env: EnvConfig.envPipeline 11 | }); 12 | const template = Template.fromStack(stack); 13 | 14 | // assert that the pipeline has a s3 bucket used to store artifacts, e.g. source code of the infrastructure repo 15 | template.resourceCountIs('AWS::S3::Bucket', 1); 16 | template.hasResource('AWS::S3::Bucket', { 17 | Properties: { 18 | BucketEncryption: { 19 | ServerSideEncryptionConfiguration: [ 20 | { 21 | ServerSideEncryptionByDefault: { 22 | SSEAlgorithm: 'aws:kms' 23 | } 24 | } 25 | ] 26 | }, 27 | PublicAccessBlockConfiguration: { 28 | BlockPublicAcls: true, 29 | BlockPublicPolicy: true, 30 | IgnorePublicAcls: true, 31 | RestrictPublicBuckets: true 32 | } 33 | }, 34 | UpdateReplacePolicy: 'Retain', 35 | DeletionPolicy: 'Retain' 36 | }); 37 | 38 | // assert the pipeline role 39 | template.hasResourceProperties('AWS::IAM::Role', { 40 | AssumeRolePolicyDocument: { 41 | Statement: [ 42 | { 43 | Action: 'sts:AssumeRole', 44 | Effect: 'Allow', 45 | Principal: { 46 | Service: 'codepipeline.amazonaws.com' 47 | } 48 | } 49 | ], 50 | Version: '2012-10-17' 51 | } 52 | }); 53 | 54 | // assert the FinchPipelineStack creates a CodePipeline resource 55 | template.hasResourceProperties('AWS::CodePipeline::Pipeline', { 56 | RoleArn: { 57 | 'Fn::GetAtt': ['FinchPipelineRole198D7E07', 'Arn'] 58 | } 59 | }); 60 | 61 | expect(stack.terminationProtection).toBeTruthy(); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/ssm-patching-stack.test.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Template } from 'aws-cdk-lib/assertions'; 3 | import { SSMPatchingStack } from '../lib/ssm-patching-stack'; 4 | 5 | describe('SSMPatchingStack', () => { 6 | test('synthesizes the way we expect', () => { 7 | const app = new cdk.App(); 8 | const ssmPatchingStack = new SSMPatchingStack(app, 'SSMPatchingStack'); 9 | 10 | const template = Template.fromStack(ssmPatchingStack); 11 | 12 | template.hasResource('AWS::SSM::MaintenanceWindow', { 13 | Properties: { 14 | AllowUnassociatedTargets: false, 15 | Cutoff: 0, 16 | Duration: 2, 17 | Name: 'Patching-Window', 18 | Schedule: 'cron(0 8 ? * * *)' 19 | } 20 | }); 21 | 22 | template.hasResource('AWS::SSM::MaintenanceWindowTarget', { 23 | Properties: { 24 | Name: 'All-Instances-Patch-Target', 25 | ResourceType: 'INSTANCE', 26 | Targets: [ 27 | { 28 | Key: 'tag:PVRE-Reporting', 29 | Values: ['SSM'] 30 | } 31 | ], 32 | WindowId: { 33 | Ref: 'MaintenanceWindow' 34 | } 35 | } 36 | }); 37 | 38 | template.hasResource('AWS::SSM::MaintenanceWindowTask', { 39 | Properties: { 40 | MaxConcurrency: '1', 41 | MaxErrors: '100%', 42 | Name: 'Patch-Task', 43 | Priority: 1, 44 | Targets: [ 45 | { 46 | Key: 'WindowTargetIds', 47 | Values: [ 48 | { 49 | Ref: 'MaintenanceWindowTarget' 50 | } 51 | ] 52 | } 53 | ], 54 | TaskArn: 'AWS-RunPatchBaseline', 55 | TaskInvocationParameters: { 56 | MaintenanceWindowRunCommandParameters: { 57 | Parameters: { 58 | Operation: ['Install'] 59 | }, 60 | DocumentVersion: '$LATEST' 61 | } 62 | }, 63 | TaskType: 'RUN_COMMAND', 64 | WindowId: { 65 | Ref: 'MaintenanceWindow' 66 | } 67 | } 68 | }); 69 | 70 | expect(ssmPatchingStack.terminationProtection).toBeTruthy(); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { toStackName } from '../lib/utils'; 2 | 3 | describe('toStackName', () => { 4 | test('should return a string with the correct format', () => { 5 | const name = 'CodebuildStack-x86_64_ubuntu'; 6 | const expected = 'CodebuildStack-x86-64-ubuntu'; 7 | const actual = toStackName(name); 8 | expect(actual).toBe(expected); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node18/tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "noUnusedLocals": false, 6 | "noUnusedParameters": false, 7 | "noImplicitReturns": true, 8 | "noFallthroughCasesInSwitch": false, 9 | "inlineSourceMap": true, 10 | "inlineSources": true, 11 | "experimentalDecorators": true, 12 | "strictPropertyInitialization": false, 13 | "resolveJsonModule": true, 14 | "outDir": "./dist", 15 | }, 16 | "exclude": [ 17 | "node_modules", 18 | "cdk.out", 19 | "dist", 20 | ] 21 | } 22 | --------------------------------------------------------------------------------