├── .github ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ └── check.yml ├── .gitignore ├── .mergify.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── alarms ├── README.md └── template.yaml ├── canaries ├── .gitignore ├── README.md ├── StackConfigProd.json ├── StackConfigTest.json ├── buildspec.yml ├── canary.js ├── package-lock.json ├── package.json └── template.yaml ├── chat-bot ├── .gitignore ├── README.md ├── StackConfig.json ├── bot │ ├── bot.js │ ├── package-lock.json │ └── package.json ├── buildspec.yml ├── hook │ ├── pre-traffic-hook.js │ └── test-events │ │ ├── final.expected.json │ │ ├── final.json │ │ ├── four.expected.json │ │ ├── four.json │ │ ├── intro.expected.json │ │ ├── intro.json │ │ ├── min.json │ │ ├── one.expected.json │ │ └── one.json ├── lex-model │ ├── .gitignore │ ├── buildspec.yml │ ├── convert-model.js │ ├── gen-lex-model.sh │ ├── lex-model-template.json │ ├── package-lock.json │ ├── package.json │ └── trivia-game-bot.json └── template.yaml ├── pipelines ├── .gitignore ├── .npmignore ├── README.md ├── package-lock.json ├── package.json ├── src │ ├── api-base-image-pipeline.ts │ ├── api-service-blue-green-pipeline.ts │ ├── api-service-codedeploy-lifecycle-event-hooks-pipeline.ts │ ├── api-service-codedeploy-pipeline.ts │ ├── api-service-pipeline.ts │ ├── canaries-pipeline.ts │ ├── chat-bot-pipeline.ts │ ├── common │ │ ├── cfn-containers-pipeline.ts │ │ └── cfn-pipeline.ts │ ├── pipelines-bootstrap.ts │ └── static-site-pipeline.ts └── tsconfig.json ├── static-site ├── README.md ├── app │ ├── .babelrc │ ├── assets │ │ ├── css │ │ │ └── styles.css │ │ └── img │ │ │ └── aws.svg │ ├── error.html │ ├── index.html │ ├── js │ │ ├── Card.js │ │ ├── Footer.js │ │ ├── Headers.js │ │ ├── app.js │ │ ├── pageNotFound.js │ │ └── request.js │ ├── package-lock.json │ ├── package.json │ ├── webpack-errorpage.config.js │ └── webpack.config.js ├── buildspec.yml └── cdk │ ├── .gitignore │ ├── .npmignore │ ├── infrastructure.ts │ ├── package-lock.json │ ├── package.json │ ├── root-domain-site.ts │ ├── static-site.ts │ └── tsconfig.json └── trivia-backend ├── .dockerignore ├── Dockerfile ├── README.md ├── app ├── apidoc.json ├── healthcheck.js ├── package-lock.json ├── package.json ├── routes │ ├── load.js │ └── trivia.js └── service.js ├── artillery-load-api.yml ├── artillery-trivia-api.yml ├── base ├── Dockerfile └── buildspec.yml ├── data └── questions.json └── infra ├── cdk ├── .gitignore ├── .npmignore ├── StackConfig.json ├── buildspec-blue-green.yml ├── buildspec.yml ├── ecs-service-blue-green.ts ├── ecs-service.ts ├── ecs-task-sets.ts ├── eks-service.ts ├── eks │ ├── alb-ingress-controller-policy.ts │ └── kubernetes-resources │ │ └── reinvent-trivia.ts ├── package-lock.json ├── package.json └── tsconfig.json ├── codedeploy-blue-green ├── .gitignore ├── appspec.json ├── buildspec.yml ├── deployment-setup.ts ├── infra-setup.ts ├── package-lock.json ├── package.json ├── produce-config.js ├── setup.sh ├── task-definition.json └── tsconfig.json └── codedeploy-lifecycle-event-hooks ├── .gitignore ├── StackConfigProd.json ├── StackConfigTest.json ├── buildspec.yml ├── package-lock.json ├── package.json ├── pre-traffic-hook.js └── template.yaml /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | *Description of changes:* 4 | 5 | 6 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/trivia-backend/infra/codedeploy-blue-green/hooks" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | versioning-strategy: increase 9 | - package-ecosystem: npm 10 | directory: "/canaries" 11 | schedule: 12 | interval: monthly 13 | open-pull-requests-limit: 10 14 | versioning-strategy: increase 15 | - package-ecosystem: npm 16 | directory: "/trivia-backend/app" 17 | schedule: 18 | interval: monthly 19 | open-pull-requests-limit: 10 20 | versioning-strategy: increase 21 | - package-ecosystem: npm 22 | directory: "/chat-bot/bot" 23 | schedule: 24 | interval: monthly 25 | open-pull-requests-limit: 10 26 | versioning-strategy: increase 27 | - package-ecosystem: npm 28 | directory: "/trivia-backend/infra/cdk" 29 | schedule: 30 | interval: monthly 31 | open-pull-requests-limit: 10 32 | versioning-strategy: increase 33 | - package-ecosystem: docker 34 | directory: "/trivia-backend/base" 35 | schedule: 36 | interval: monthly 37 | open-pull-requests-limit: 10 38 | - package-ecosystem: npm 39 | directory: "/chat-bot/lex-model" 40 | schedule: 41 | interval: monthly 42 | open-pull-requests-limit: 10 43 | versioning-strategy: increase 44 | - package-ecosystem: npm 45 | directory: "/pipelines" 46 | schedule: 47 | interval: monthly 48 | open-pull-requests-limit: 10 49 | versioning-strategy: increase 50 | - package-ecosystem: npm 51 | directory: "/static-site/app" 52 | schedule: 53 | interval: monthly 54 | open-pull-requests-limit: 10 55 | versioning-strategy: increase 56 | - package-ecosystem: npm 57 | directory: "/static-site/cdk" 58 | schedule: 59 | interval: monthly 60 | open-pull-requests-limit: 10 61 | versioning-strategy: increase 62 | - package-ecosystem: npm 63 | directory: "/trivia-backend/infra/codedeploy-blue-green" 64 | schedule: 65 | interval: monthly 66 | open-pull-requests-limit: 10 67 | versioning-strategy: increase 68 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | on: 2 | [pull_request, push] 3 | 4 | name: Check 5 | 6 | jobs: 7 | pipelines: 8 | name: Pipelines 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 20 15 | - name: Test 16 | run: | 17 | cd pipelines 18 | 19 | npm install -g aws-cdk 20 | npm ci 21 | 22 | npm run build 23 | npm run synth-static-site-pipeline 24 | npm run synth-backend-pipeline 25 | npm run synth-backend-blue-green-pipeline 26 | npm run synth-backend-codedeploy-pipeline 27 | npm run synth-backend-base-image-pipeline 28 | npm run synth-chat-bot-pipeline 29 | npm run synth-canaries-pipeline 30 | npm run synth-lifecycle-hooks-pipeline 31 | npm run synth-pipelines-bootstrap 32 | 33 | static-site: 34 | name: Static Site 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v3 38 | - uses: actions/setup-node@v3 39 | with: 40 | node-version: 20 41 | - name: Test 42 | run: | 43 | npm install -g aws-cdk 44 | 45 | cd static-site/cdk 46 | npm ci 47 | npm run build 48 | 49 | cd ../app 50 | npm ci 51 | npm run build 52 | npm run build:dev 53 | npm run build:test 54 | npm run build:prod 55 | 56 | game-infra: 57 | name: Game Infra 58 | runs-on: ubuntu-latest 59 | steps: 60 | - uses: actions/checkout@v3 61 | - uses: actions/setup-node@v3 62 | with: 63 | node-version: 20 64 | - name: Test 65 | run: | 66 | npm install -g aws-cdk 67 | 68 | cd trivia-backend/infra/cdk 69 | npm ci 70 | npm run build 71 | 72 | cd ../codedeploy-blue-green 73 | npm ci 74 | npm run build 75 | 76 | cd ../codedeploy-lifecycle-event-hooks 77 | npm ci 78 | 79 | game-app: 80 | name: Game App 81 | runs-on: ubuntu-latest 82 | steps: 83 | - uses: actions/checkout@v3 84 | - uses: actions/setup-node@v3 85 | with: 86 | node-version: 20 87 | - name: Test 88 | run: | 89 | cd trivia-backend/base 90 | docker build -t reinvent-trivia-backend-base:release . 91 | 92 | cd .. 93 | docker build -t reinvent-trivia-backend:release . 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.d.ts 3 | node_modules 4 | cdk.json 5 | cdk.context.json 6 | cdk.out 7 | 8 | *.swp 9 | *.iml 10 | .idea 11 | .vscode 12 | 13 | build/ 14 | 15 | # Logs 16 | logs 17 | *.log 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # Runtime data 23 | pids 24 | *.pid 25 | *.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | lib-cov 30 | 31 | # Coverage directory used by tools like istanbul 32 | coverage 33 | 34 | # nyc test coverage 35 | .nyc_output 36 | 37 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 38 | .grunt 39 | 40 | # Bower dependency directory (https://bower.io/) 41 | bower_components 42 | 43 | # node-waf configuration 44 | .lock-wscript 45 | 46 | # Compiled binary addons (https://nodejs.org/api/addons.html) 47 | build/Release 48 | 49 | # Dependency directories 50 | node_modules/ 51 | jspm_packages/ 52 | 53 | # TypeScript v1 declaration files 54 | typings/ 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | 74 | # next.js build output 75 | .next 76 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | queue_rules: 2 | - name: default 3 | queue_conditions: 4 | - base=master 5 | - author=dependabot[bot] 6 | - label=dependencies 7 | - -title~=(WIP|wip) 8 | - -label~=(blocked|do-not-merge) 9 | - -merged 10 | - -closed 11 | merge_conditions: 12 | - status-success=Game App 13 | - status-success=Game Infra 14 | - status-success=Pipelines 15 | - status-success=Static Site 16 | merge_method: squash 17 | 18 | pull_request_rules: 19 | - name: Automatically merge Dependabot PRs 20 | conditions: 21 | - base=master 22 | - author=dependabot[bot] 23 | - label=dependencies 24 | - -title~=(WIP|wip) 25 | - -label~=(blocked|do-not-merge) 26 | - -merged 27 | - -closed 28 | actions: 29 | review: 30 | type: APPROVE 31 | queue: 32 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check [existing open](https://github.com/aws-samples/aws-reinvent-trivia-game/issues), or [recently closed](https://github.com/aws-samples/aws-reinvent-trivia-game/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/aws-samples/aws-reinvent-trivia-game/labels/help%20wanted) issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](https://github.com/aws-samples/aws-reinvent-trivia-game/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2018 Amazon.com, Inc. or its affiliates. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## AWS re:Invent Trivia Game 2 | 3 | Sample trivia game built with AWS Fargate, AWS Lambda, and Amazon Lex. See [reinvent-trivia.com](https://www.reinvent-trivia.com) for a running example. 4 | 5 | ## Components 6 | 7 | * **Backend API Service** ([folder](trivia-backend/)): REST API that serves trivia questions and answers. Runs on AWS Fargate, either with Amazon ECS or with Amazon EKS. 8 | * **Static Site** ([folder](static-site/)): Web application page, backed by Amazon S3, Amazon CloudFront, and Amazon Route53. 9 | * **Chat Bot** ([folder](chat-bot/)): Conversational bot that asks trivia questions and validates answers, and can be integrated into Slack workspace. Running on Amazon Lex and AWS Lambda. 10 | * **Continuous Delivery** ([folder](pipelines/)): Pipelines that deploy code and infrastructure for each of the components. 11 | * **Canaries** ([folder](canaries/)): Monitoring canaries to continuously test the application and alarm in case of issues. 12 | * **Alarms** ([folder](alarms/)): E-mail and chat notifications for alarms in case of issues. 13 | 14 | The components above are almost entirely deployed with AWS CloudFormation, using either the [AWS Cloud Development Kit](https://aws.amazon.com/cdk/) or the [AWS Serverless Application Model](https://aws.amazon.com/serverless/sam/). 15 | 16 | ## License Summary 17 | 18 | This sample code is made available under the MIT license. See the LICENSE file. 19 | 20 | ## Credits 21 | 22 | Static site based on [React Trivia](https://github.com/ccoenraets/react-trivia) 23 | -------------------------------------------------------------------------------- /alarms/README.md: -------------------------------------------------------------------------------- 1 | # Alarms 2 | 3 | Assuming you have set up all the other components for the trivia game already (including chat bot, backend service, static site, and canaries), these instructions set up [Amazon CloudWatch composite alarms](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Create_Composite_Alarm.html) to notify you if any of these components has issues. 4 | 5 | ## Pre-requisite resources 6 | 7 | First, create an SNS topic for notifications about the composite alarms. 8 | ``` 9 | aws sns create-topic --name reinvent-trivia-notifications --region us-east-1 10 | ``` 11 | 12 | To subscribe an email address to receive notifications about alarms, follow the instructions for subscribing via email to the SNS topic on [this page](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/US_SetupSNS.html#set-up-sns-topic-cli). 13 | 14 | To create a chat bot that notifies you about alarms in Slack or Chime, follow the instructions for connecting AWS Chat Bot to the SNS topic on [this page](https://docs.aws.amazon.com/chatbot/latest/adminguide/setting-up.html). 15 | 16 | ## Create the test endpoint composite alarm 17 | 18 | ``` 19 | aws cloudformation deploy \ 20 | --region us-east-1 \ 21 | --template-file template.yaml \ 22 | --stack-name TriviaGameCompositeAlarmTest \ 23 | --parameter-overrides Stage=Test \ 24 | --tags project=reinvent-trivia 25 | ``` 26 | 27 | ## Create the prod endpoint composite alarm 28 | 29 | ``` 30 | aws cloudformation deploy \ 31 | --region us-east-1 \ 32 | --template-file template.yaml \ 33 | --stack-name TriviaGameCompositeAlarmProd \ 34 | --parameter-overrides Stage=Prod \ 35 | --tags project=reinvent-trivia 36 | ``` 37 | -------------------------------------------------------------------------------- /alarms/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: Composite alarm resources for trivia site 3 | 4 | Parameters: 5 | Stage: 6 | Type: String 7 | AllowedValues: 8 | - "Test" 9 | - "Prod" 10 | 11 | NotificationsTopic: 12 | Type: String 13 | Default: reinvent-trivia-notifications 14 | 15 | Mappings: 16 | StageLowerCaseMap: 17 | Test: { "Lower": "test" } 18 | Prod: { "Lower": "prod" } 19 | 20 | Resources: 21 | CompositeAlarm: 22 | Type: AWS::CloudWatch::CompositeAlarm 23 | Properties: 24 | AlarmName: !Sub "TriviaGame-CompositeAlarm-${Stage}" 25 | AlarmDescription: "Composite alarm that aggregates all individual component alarms" 26 | ActionsEnabled: true 27 | AlarmActions: 28 | - !Sub "arn:${AWS::Partition}:sns:${AWS::Region}:${AWS::AccountId}:${NotificationsTopic}" 29 | AlarmRule: !Sub 30 | - |- 31 | ALARM("TriviaBackend${Stage}-Unhealthy-Hosts") OR 32 | ALARM("TriviaBackend${Stage}-Http-500") OR 33 | ALARM("TriviaGameChatBot${Stage}-BotLatestVersionErrors") OR 34 | ALARM("TriviaGameChatBot${Stage}-BotAliasErrors") OR 35 | ALARM("Synthetics-Alarm-trivia-game-${StageLowerCase}") 36 | - { StageLowerCase: !FindInMap ["StageLowerCaseMap", !Ref Stage, "Lower"] } 37 | -------------------------------------------------------------------------------- /canaries/.gitignore: -------------------------------------------------------------------------------- 1 | nodejs/ 2 | trivia-game-canary-code.zip 3 | -------------------------------------------------------------------------------- /canaries/README.md: -------------------------------------------------------------------------------- 1 | # Canaries 2 | 3 | The trivia game application can use Amazon CloudWatch Synthetics to continuously load the webpage and APIs, and alarm when the page does not load or does not render correctly. 4 | 5 | ## Prep 6 | 7 | Create an SNS topic for notifications about the canary alarms. An email address or to a [chat bot](https://docs.aws.amazon.com/chatbot/latest/adminguide/setting-up.html) can then be subscribed to the topic to receive notifications about canary alarms. 8 | ``` 9 | aws sns create-topic --name reinvent-trivia-notifications --region us-east-1 10 | ``` 11 | 12 | ## Customize 13 | 14 | Replace all references to 'reinvent-trivia.com' with your own domain name. 15 | 16 | ## Deploy 17 | 18 | Ideally, use the pipelines in the "[pipelines](../pipelines/)" folder to deploy the canaries. Alternatively, you can use the AWS CLI to deploy. 19 | 20 | These instructions require an S3 bucket to store the canary source code, marked as `$BUCKET_NAME` below. 21 | 22 | ### Package the canary code 23 | 24 | Package and upload the canary script: 25 | 26 | ``` 27 | npm install 28 | mkdir -p nodejs/ 29 | cp -a node_modules/ nodejs/ 30 | cp canary.js nodejs/node_modules/ 31 | zip -r trivia-game-canary-code.zip nodejs/ 32 | aws s3 cp trivia-game-canary-code.zip s3://$BUCKET_NAME/ 33 | ``` 34 | 35 | ### Create the test endpoint canary 36 | 37 | Deploy the resources for running a continuous monitoring canary against the test endpoints: 38 | 39 | ``` 40 | aws cloudformation deploy \ 41 | --region us-east-1 \ 42 | --template-file template.yaml \ 43 | --stack-name TriviaGameCanariesTest \ 44 | --capabilities CAPABILITY_NAMED_IAM \ 45 | --parameter-overrides Stage=test SourceBucket=$BUCKET_NAME SourceObjectKey="trivia-game-canary-code.zip" WebpageUrl="https://test.reinvent-trivia.com" ApiEndpoint="https://api-test.reinvent-trivia.com/" \ 46 | --tags project=reinvent-trivia 47 | ``` 48 | 49 | ### Create the production endpoint canary 50 | 51 | Deploy the resources for running a continuous monitoring canary against the production endpoints: 52 | 53 | ``` 54 | aws cloudformation deploy \ 55 | --region us-east-1 \ 56 | --template-file template.yaml \ 57 | --stack-name TriviaGameCanariesProd \ 58 | --capabilities CAPABILITY_NAMED_IAM \ 59 | --parameter-overrides Stage=prod SourceBucket=$BUCKET_NAME SourceObjectKey="trivia-game-canary-code.zip" WebpageUrl="https://www.reinvent-trivia.com" ApiEndpoint="https://api.reinvent-trivia.com/" \ 60 | --tags project=reinvent-trivia 61 | ``` 62 | -------------------------------------------------------------------------------- /canaries/StackConfigProd.json: -------------------------------------------------------------------------------- 1 | { 2 | "Tags" : { 3 | "project" : "reinvent-trivia" 4 | }, 5 | "Parameters" : { 6 | "Stage" : "prod", 7 | "SourceBucket": "SOURCE_BUCKET", 8 | "SourceObjectKey": "SOURCE_OBJECT_KEY", 9 | "WebpageUrl": "https://www.reinvent-trivia.com", 10 | "ApiEndpoint": "https://api.reinvent-trivia.com/" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /canaries/StackConfigTest.json: -------------------------------------------------------------------------------- 1 | { 2 | "Tags" : { 3 | "project" : "reinvent-trivia" 4 | }, 5 | "Parameters" : { 6 | "Stage" : "test", 7 | "SourceBucket": "SOURCE_BUCKET", 8 | "SourceObjectKey": "SOURCE_OBJECT_KEY", 9 | "WebpageUrl": "https://test.reinvent-trivia.com", 10 | "ApiEndpoint": "https://api-test.reinvent-trivia.com/" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /canaries/buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | runtime-versions: 6 | nodejs: latest 7 | commands: 8 | - cd canaries 9 | - npm ci 10 | - export CANARY_CODE_FILENAME=trivia-game-canary-code-`echo $CODEBUILD_BUILD_ID | awk -F":" '{print $2}'`.zip 11 | build: 12 | commands: 13 | - mkdir -p nodejs/ 14 | - cp -a node_modules/ nodejs/ 15 | - cp canary.js nodejs/node_modules/ 16 | - zip -r $CANARY_CODE_FILENAME nodejs/ 17 | - aws s3 cp $CANARY_CODE_FILENAME s3://$ARTIFACTS_BUCKET/ 18 | 19 | - cp template.yaml TriviaGameCanariesTest.template.yaml 20 | - cp template.yaml TriviaGameCanariesProd.template.yaml 21 | 22 | - sed -i "s/SOURCE_BUCKET/$ARTIFACTS_BUCKET/g" StackConfigTest.json StackConfigProd.json 23 | - sed -i "s/SOURCE_OBJECT_KEY/$CANARY_CODE_FILENAME/g" StackConfigTest.json StackConfigProd.json 24 | 25 | artifacts: 26 | files: 27 | - canaries/TriviaGameCanariesTest.template.yaml 28 | - canaries/TriviaGameCanariesProd.template.yaml 29 | - canaries/StackConfigTest.json 30 | - canaries/StackConfigProd.json 31 | discard-paths: yes 32 | -------------------------------------------------------------------------------- /canaries/canary.js: -------------------------------------------------------------------------------- 1 | var synthetics = require('Synthetics'); 2 | const log = require('SyntheticsLogger'); 3 | const axios = require('axios'); 4 | 5 | const PAGE_URL = process.env['WEBPAGE_URL']; 6 | const API_ENDPOINT = process.env['API_ENDPOINT']; 7 | 8 | const loadPage = async function () { 9 | let page = await synthetics.getPage(); 10 | const response = await page.goto(PAGE_URL, {waitUntil: 'domcontentloaded', timeout: 10000}); 11 | if (!response) { 12 | throw "Failed to load page!"; 13 | } 14 | await page.waitForTimeout(5000); 15 | await synthetics.takeScreenshot('loaded', 'loaded'); 16 | let pageTitle = await page.title(); 17 | log.info('Page title: ' + pageTitle); 18 | if (response.status() !== 200) { 19 | throw "Failed to load page!"; 20 | } 21 | }; 22 | 23 | const getAllQuestionsApi = async function () { 24 | log.info("Getting all questions"); 25 | const response = await axios({ 26 | url: '/api/trivia/all', 27 | method: 'get', 28 | baseURL: API_ENDPOINT, 29 | headers: { 30 | 'User-Agent': synthetics.getCanaryUserAgentString() 31 | } 32 | }); 33 | log.info("Response:"); 34 | log.info(response); 35 | if (response.status != 200) { 36 | throw "Failed to load questions!"; 37 | } else if (response.data.length != 4) { 38 | throw "Wrong number of categories!"; 39 | } 40 | }; 41 | 42 | const getQuestionApi = async function () { 43 | log.info("Getting question 1"); 44 | const response = await axios({ 45 | url: '/api/trivia/question/1', 46 | method: 'get', 47 | baseURL: API_ENDPOINT, 48 | headers: { 49 | 'User-Agent': synthetics.getCanaryUserAgentString() 50 | } 51 | }); 52 | log.info("Response:"); 53 | log.info(response); 54 | if (response.status != 200 || 55 | response.data.question != "What year was the first AWS re:Invent held?") { 56 | throw "Failed to load question!"; 57 | } 58 | }; 59 | 60 | const answerQuestionApi = async function () { 61 | log.info("Answering question 1"); 62 | const response = await axios({ 63 | url: '/api/trivia/question/1', 64 | method: 'post', 65 | baseURL: API_ENDPOINT, 66 | headers: { 67 | 'User-Agent': synthetics.getCanaryUserAgentString() 68 | }, 69 | data: { 70 | answer: '2012' 71 | } 72 | }); 73 | log.info("Response:"); 74 | log.info(response); 75 | if (response.status != 200 || 76 | !response.data.result) { 77 | throw "Failed to answer question!"; 78 | } 79 | }; 80 | 81 | exports.handler = async () => { 82 | await loadPage(); 83 | await getAllQuestionsApi(); 84 | await getQuestionApi(); 85 | await answerQuestionApi(); 86 | }; 87 | -------------------------------------------------------------------------------- /canaries/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-trivia-game-canary", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "aws-trivia-game-canary", 9 | "version": "1.0.0", 10 | "dependencies": { 11 | "axios": "^1.9.0" 12 | } 13 | }, 14 | "node_modules/asynckit": { 15 | "version": "0.4.0", 16 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 17 | "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", 18 | "license": "MIT" 19 | }, 20 | "node_modules/axios": { 21 | "version": "1.9.0", 22 | "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", 23 | "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", 24 | "license": "MIT", 25 | "dependencies": { 26 | "follow-redirects": "^1.15.6", 27 | "form-data": "^4.0.0", 28 | "proxy-from-env": "^1.1.0" 29 | } 30 | }, 31 | "node_modules/combined-stream": { 32 | "version": "1.0.8", 33 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 34 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 35 | "license": "MIT", 36 | "dependencies": { 37 | "delayed-stream": "~1.0.0" 38 | }, 39 | "engines": { 40 | "node": ">= 0.8" 41 | } 42 | }, 43 | "node_modules/delayed-stream": { 44 | "version": "1.0.0", 45 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 46 | "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", 47 | "license": "MIT", 48 | "engines": { 49 | "node": ">=0.4.0" 50 | } 51 | }, 52 | "node_modules/follow-redirects": { 53 | "version": "1.15.9", 54 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", 55 | "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", 56 | "funding": [ 57 | { 58 | "type": "individual", 59 | "url": "https://github.com/sponsors/RubenVerborgh" 60 | } 61 | ], 62 | "license": "MIT", 63 | "engines": { 64 | "node": ">=4.0" 65 | }, 66 | "peerDependenciesMeta": { 67 | "debug": { 68 | "optional": true 69 | } 70 | } 71 | }, 72 | "node_modules/form-data": { 73 | "version": "4.0.1", 74 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", 75 | "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", 76 | "license": "MIT", 77 | "dependencies": { 78 | "asynckit": "^0.4.0", 79 | "combined-stream": "^1.0.8", 80 | "mime-types": "^2.1.12" 81 | }, 82 | "engines": { 83 | "node": ">= 6" 84 | } 85 | }, 86 | "node_modules/mime-db": { 87 | "version": "1.52.0", 88 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 89 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", 90 | "license": "MIT", 91 | "engines": { 92 | "node": ">= 0.6" 93 | } 94 | }, 95 | "node_modules/mime-types": { 96 | "version": "2.1.35", 97 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 98 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 99 | "license": "MIT", 100 | "dependencies": { 101 | "mime-db": "1.52.0" 102 | }, 103 | "engines": { 104 | "node": ">= 0.6" 105 | } 106 | }, 107 | "node_modules/proxy-from-env": { 108 | "version": "1.1.0", 109 | "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", 110 | "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", 111 | "license": "MIT" 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /canaries/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-trivia-game-canary", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "axios": "^1.9.0" 6 | }, 7 | "private": true 8 | } 9 | -------------------------------------------------------------------------------- /canaries/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: Canary resources for trivia site 3 | 4 | Parameters: 5 | Stage: 6 | Type: String 7 | AllowedValues: 8 | - "test" 9 | - "prod" 10 | 11 | WebpageUrl: 12 | Type: String 13 | 14 | ApiEndpoint: 15 | Type: String 16 | 17 | SourceBucket: 18 | Type: String 19 | 20 | SourceObjectKey: 21 | Type: String 22 | 23 | NotificationsTopic: 24 | Type: String 25 | Default: reinvent-trivia-notifications 26 | 27 | Resources: 28 | Canary: 29 | Type: AWS::Synthetics::Canary 30 | DependsOn: 31 | - CanaryResultsBucket 32 | - CanaryRole 33 | - CanaryAlarm 34 | Properties: 35 | Name: !Sub "trivia-game-${Stage}" 36 | ExecutionRoleArn: !GetAtt CanaryRole.Arn 37 | Code: 38 | Handler: canary.handler 39 | S3Bucket: !Ref SourceBucket 40 | S3Key: !Ref SourceObjectKey 41 | ArtifactS3Location: !Sub "s3://${CanaryResultsBucket}" 42 | RuntimeVersion: syn-nodejs-puppeteer-7.0 43 | Schedule: 44 | Expression: 'rate(5 minutes)' 45 | DurationInSeconds: 0 46 | RunConfig: 47 | TimeoutInSeconds: 60 48 | EnvironmentVariables: 49 | WEBPAGE_URL: !Ref WebpageUrl 50 | API_ENDPOINT: !Ref ApiEndpoint 51 | FailureRetentionPeriod: 30 52 | SuccessRetentionPeriod: 30 53 | StartCanaryAfterCreation: true 54 | 55 | CanaryAlarm: 56 | Type: AWS::CloudWatch::Alarm 57 | Properties: 58 | AlarmName: !Sub "Synthetics-Alarm-trivia-game-${Stage}" 59 | AlarmDescription: "Synthetics alarm thresholds: 2 test failures in 10 mins" 60 | ActionsEnabled: true 61 | AlarmActions: 62 | - !Sub "arn:${AWS::Partition}:sns:${AWS::Region}:${AWS::AccountId}:${NotificationsTopic}" 63 | MetricName: SuccessPercent 64 | Namespace: CloudWatchSynthetics 65 | Statistic: Sum 66 | Period: 300 67 | EvaluationPeriods: 2 68 | DatapointsToAlarm: 2 69 | Threshold: 0.0 70 | TreatMissingData: breaching 71 | ComparisonOperator: LessThanOrEqualToThreshold 72 | Dimensions: 73 | - Name: CanaryName 74 | Value: !Sub "trivia-game-${Stage}" 75 | 76 | CanaryResultsBucket: 77 | Type: AWS::S3::Bucket 78 | DeletionPolicy: Delete 79 | Properties: 80 | LifecycleConfiguration: 81 | Rules: 82 | - Status: Enabled 83 | ExpirationInDays: 31 84 | 85 | CanaryRole: 86 | Type: AWS::IAM::Role 87 | Properties: 88 | RoleName: !Sub "CloudWatchSyntheticsRole-trivia-game-${Stage}" 89 | AssumeRolePolicyDocument: 90 | Statement: 91 | - Effect: Allow 92 | Principal: 93 | Service: 94 | - lambda.amazonaws.com 95 | Action: 96 | - sts:AssumeRole 97 | Path: / 98 | Policies: 99 | - PolicyName: !Sub "CloudWatchSyntheticsPolicy-trivia-game-${Stage}" 100 | PolicyDocument: 101 | Statement: 102 | - Effect: Allow 103 | Action: 104 | - "s3:PutObject" 105 | Resource: !Sub "arn:aws:s3:::${CanaryResultsBucket}/*" 106 | - Effect: Allow 107 | Action: 108 | - "logs:CreateLogStream" 109 | - "logs:PutLogEvents" 110 | - "logs:CreateLogGroup" 111 | Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/cwsyn-trivia-game-${Stage}-*" 112 | - Effect: Allow 113 | Action: 114 | - "s3:GetBucketLocation" 115 | - "s3:ListAllMyBuckets" 116 | Resource: "*" 117 | - Effect: Allow 118 | Action: 119 | - "cloudwatch:PutMetricData" 120 | Resource: "*" 121 | Condition: 122 | StringEquals: 123 | "cloudwatch:namespace": CloudWatchSynthetics 124 | -------------------------------------------------------------------------------- /chat-bot/.gitignore: -------------------------------------------------------------------------------- 1 | bot.zip 2 | 3 | # Created by https://www.gitignore.io/api/osx,node,linux,windows 4 | 5 | ### Linux ### 6 | *~ 7 | 8 | # temporary files which can be created if a process still has a handle open of a deleted file 9 | .fuse_hidden* 10 | 11 | # KDE directory preferences 12 | .directory 13 | 14 | # Linux trash folder which might appear on any partition or disk 15 | .Trash-* 16 | 17 | # .nfs files are created when an open file is removed but is still being accessed 18 | .nfs* 19 | 20 | ### Node ### 21 | # Logs 22 | logs 23 | *.log 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # Runtime data 29 | pids 30 | *.pid 31 | *.seed 32 | *.pid.lock 33 | 34 | # Directory for instrumented libs generated by jscoverage/JSCover 35 | lib-cov 36 | 37 | # Coverage directory used by tools like istanbul 38 | coverage 39 | 40 | # nyc test coverage 41 | .nyc_output 42 | 43 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 44 | .grunt 45 | 46 | # Bower dependency directory (https://bower.io/) 47 | bower_components 48 | 49 | # node-waf configuration 50 | .lock-wscript 51 | 52 | # Compiled binary addons (http://nodejs.org/api/addons.html) 53 | build/Release 54 | 55 | # Dependency directories 56 | node_modules/ 57 | jspm_packages/ 58 | 59 | # Typescript v1 declaration files 60 | typings/ 61 | 62 | # Optional npm cache directory 63 | .npm 64 | 65 | # Optional eslint cache 66 | .eslintcache 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variables file 78 | .env 79 | 80 | 81 | ### OSX ### 82 | *.DS_Store 83 | .AppleDouble 84 | .LSOverride 85 | 86 | # Icon must end with two \r 87 | Icon 88 | 89 | # Thumbnails 90 | ._* 91 | 92 | # Files that might appear in the root of a volume 93 | .DocumentRevisions-V100 94 | .fseventsd 95 | .Spotlight-V100 96 | .TemporaryItems 97 | .Trashes 98 | .VolumeIcon.icns 99 | .com.apple.timemachine.donotpresent 100 | 101 | # Directories potentially created on remote AFP share 102 | .AppleDB 103 | .AppleDesktop 104 | Network Trash Folder 105 | Temporary Items 106 | .apdisk 107 | 108 | ### Windows ### 109 | # Windows thumbnail cache files 110 | Thumbs.db 111 | ehthumbs.db 112 | ehthumbs_vista.db 113 | 114 | # Folder config file 115 | Desktop.ini 116 | 117 | # Recycle Bin used on file shares 118 | $RECYCLE.BIN/ 119 | 120 | # Windows Installer files 121 | *.cab 122 | *.msi 123 | *.msm 124 | *.msp 125 | 126 | # Windows shortcuts 127 | *.lnk 128 | 129 | 130 | # End of https://www.gitignore.io/api/osx,node,linux,windows -------------------------------------------------------------------------------- /chat-bot/README.md: -------------------------------------------------------------------------------- 1 | # Trivia chat bot 2 | 3 | The chat bot is based on Amazon Lex, with an AWS Lambda function driving the bot conversation to ask questions and check answers. 4 | 5 | ## Bot function 6 | 7 | The bot uses the AWS Serverless Application Model (SAM) to model, package, and deploy the function code to AWS Lambda. The function queries the backend API service to get questions and to check whether answer responses are correct. The function uses the Lex session attributes to keep track of which questions have been asked and the user's score. 8 | 9 | ## Safe deployments 10 | 11 | The chat-bot uses canary deployments using AWS CodeDeploy. When the serverless application is deployed with AWS CloudFormation, a CodeDeploy deployment is automatically triggered. Both alarms and a pre-traffic validation function will be provisioned with the stack, and CodeDeploy will use those to validate that the deployment will not impact your traffic. CodeDeploy will shift 10 percent of traffic to the new function code for 5 minutes, then will shift the rest if no alarms have triggered in that time. 12 | 13 | ## Lex model 14 | 15 | The Lex model is built from the questions and answers found in the [trivia-backend](../trivia-backend/data/questions.json) folder. Each question is a slot, which the answer as the slot type. The data file contains "alternative answers", which are used as synonyms for the slot type. In order to associate the Lex bot with Slack, [follow these instructions](https://docs.aws.amazon.com/lex/latest/dg/slack-bot-association.html). 16 | 17 | ## Prep 18 | 19 | Create a service-linked IAM role for Lex: 20 | 21 | ``` 22 | aws iam create-service-linked-role --aws-service-name lex.amazonaws.com 23 | ``` 24 | 25 | ## Customize 26 | 27 | Replace all references to 'reinvent-trivia.com' with your own domain name. 28 | 29 | ## Deploy 30 | 31 | Ideally, use the pipelines in the "[pipelines](../pipelines/)" folder to deploy the bot. Alternatively, you can use the SAM CLI to deploy. See the buildspec.yml for additional required commands, like installing dependencies. 32 | 33 | ```bash 34 | sam package \ 35 | --template-file template.yaml \ 36 | --output-template-file packaged.yaml \ 37 | --s3-bucket REPLACE_THIS_WITH_YOUR_S3_BUCKET_NAME 38 | 39 | sam deploy \ 40 | --template-file packaged.yaml \ 41 | --stack-name chat-bot \ 42 | --capabilities CAPABILITY_IAM 43 | ``` 44 | 45 | ## Local testing with SAM CLI 46 | 47 | ``` 48 | sam local invoke BotFunction --skip-pull-image -e hook/test-events/four.json 49 | 50 | echo '{"DeploymentId":"123","LifecycleEventHookExecutionId":"456"}' | sam local invoke PreTrafficHook --skip-pull-image 51 | ``` 52 | -------------------------------------------------------------------------------- /chat-bot/StackConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "Tags" : { 3 | "project" : "reinvent-trivia" 4 | } 5 | } -------------------------------------------------------------------------------- /chat-bot/bot/bot.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const numToWords = require('number-to-words'); 3 | 4 | const API_ENDPOINT = process.env.API_ENDPOINT; 5 | 6 | const MAX_QUESTIONS = 16; 7 | 8 | /** 9 | * This function is used as a fulfillment hook for an Amazon Lex bot. 10 | * It is responsible for driving the conversation including which 11 | * questions to ask, validating the user's answers, and keeping track 12 | * of the user's score. All state is tracked in the session attributes. 13 | */ 14 | 15 | // Helper functions for returning results back to Lex 16 | function elicitSlot(sessionAttributes, intentName, slots, slotToElicit, message) { 17 | return { 18 | sessionAttributes, 19 | dialogAction: { 20 | type: 'ElicitSlot', 21 | intentName, 22 | slots, 23 | slotToElicit, 24 | message, 25 | }, 26 | }; 27 | } 28 | 29 | function close(sessionAttributes, fulfillmentState, message) { 30 | return { 31 | sessionAttributes, 32 | dialogAction: { 33 | type: 'Close', 34 | fulfillmentState, 35 | message, 36 | }, 37 | }; 38 | } 39 | 40 | // Get new question from backend 41 | async function getQuestion(id) { 42 | const url = API_ENDPOINT + '/api/trivia/question/' + id; 43 | console.log('Requesting (GET) ' + url); 44 | return await axios.get(url); 45 | } 46 | 47 | // Post answer to backend 48 | async function answerQuestion(id, answer) { 49 | const url = API_ENDPOINT + '/api/trivia/question/' + id; 50 | console.log('Requesting (POST) ' + url + ' with answer ' + answer); 51 | return await axios.post(url, { answer }); 52 | } 53 | 54 | // Ask the first question 55 | async function startGame(intentRequest) { 56 | // Start of the game 57 | const sessionAttributes = { 58 | started: true, 59 | currentQuestion: 1, 60 | currentSlot: "one", 61 | currentScore: 0, 62 | }; 63 | 64 | const firstQuestion = await getQuestion(1); 65 | 66 | var message = { 67 | contentType: 'PlainText', 68 | content: "Let's play re:Invent Trivia! " + 69 | "The game covers four categories with four questions each. " + 70 | `Starting with the "${firstQuestion.data.category}" category, ` + 71 | `for ${firstQuestion.data.points} points: ${firstQuestion.data.question}` 72 | }; 73 | 74 | return elicitSlot(sessionAttributes, intentRequest.currentIntent.name, 75 | intentRequest.currentIntent.slots, "one", message); 76 | } 77 | 78 | // Check the answer to the previous question and ask the next question 79 | async function nextQuestion(intentRequest) { 80 | var sessionAttributes = intentRequest.sessionAttributes; 81 | var score = parseInt(sessionAttributes.currentScore, 10); 82 | var currentQuestionId = parseInt(sessionAttributes.currentQuestion, 10); 83 | var currentSlot = numToWords.toWords(currentQuestionId); 84 | 85 | const currentQuestionData = await getQuestion(currentQuestionId); 86 | 87 | var nextQuestionId = currentQuestionId + 1; 88 | var nextSlot = numToWords.toWords(nextQuestionId); 89 | 90 | // Check the answer, add to score if correct 91 | var isCorrect = false; 92 | var userAnswer = intentRequest.currentIntent.slots[currentSlot]; 93 | // null user answer means a string response did not match any of the sample utterances 94 | if (userAnswer) { 95 | const answerData = await answerQuestion(currentQuestionId, userAnswer); 96 | console.log(answerData.data); 97 | isCorrect = answerData.data.result; 98 | } 99 | 100 | var messageContent = ""; 101 | 102 | if (isCorrect) { 103 | score += currentQuestionData.data.points; 104 | messageContent += `That is correct! The answer is "${currentQuestionData.data.answer}". New score is ${score} points! `; 105 | } else { 106 | messageContent += `Incorrect! The correct answer is "${currentQuestionData.data.answer}". `; 107 | } 108 | 109 | const newQuestionData = await getQuestion(nextQuestionId); 110 | if (currentQuestionData.data.category != newQuestionData.data.category) { 111 | messageContent += `Moving on the "${newQuestionData.data.category}" category. ` 112 | } 113 | messageContent += `For ${newQuestionData.data.points} points: ${newQuestionData.data.question}`; 114 | 115 | var message = { 116 | contentType: 'PlainText', 117 | content: messageContent 118 | }; 119 | 120 | sessionAttributes.currentQuestion = nextQuestionId; 121 | sessionAttributes.currentSlot = nextSlot; 122 | sessionAttributes.currentScore = score; 123 | 124 | return elicitSlot(sessionAttributes, intentRequest.currentIntent.name, 125 | intentRequest.currentIntent.slots, nextSlot, message); 126 | } 127 | 128 | // Check answer to final question and finish the game 129 | async function finishGame(intentRequest) { 130 | var sessionAttributes = intentRequest.sessionAttributes; 131 | var score = parseInt(sessionAttributes.currentScore, 10); 132 | var currentQuestionId = parseInt(sessionAttributes.currentQuestion, 10); 133 | var currentSlot = numToWords.toWords(currentQuestionId); 134 | 135 | const currentQuestionData = await getQuestion(currentQuestionId); 136 | 137 | // Check the answer, add to score if correct 138 | var isCorrect = false; 139 | var userAnswer = intentRequest.currentIntent.slots[currentSlot]; 140 | // null user answer means a string response did not match any of the sample utterances 141 | if (userAnswer) { 142 | const answerData = await answerQuestion(currentQuestionId, userAnswer); 143 | console.log(answerData.data); 144 | isCorrect = answerData.data.result; 145 | } 146 | 147 | var messageContent = ""; 148 | 149 | if (isCorrect) { 150 | score += currentQuestionData.data.points; 151 | messageContent += "That is correct! "; 152 | } else { 153 | messageContent += "Incorrect! "; 154 | } 155 | messageContent += `Thanks for playing! Your final score is ${score} points`; 156 | 157 | var message = { 158 | contentType: 'PlainText', 159 | content: messageContent 160 | }; 161 | 162 | return close(sessionAttributes, 'Fulfilled', message); 163 | } 164 | 165 | // Move the game forward, based on state stored in the session attributes 166 | async function playGame(intentRequest) { 167 | const sessionAttributes = intentRequest.sessionAttributes || {}; 168 | const slots = intentRequest.currentIntent.slots; 169 | 170 | if (Object.keys(sessionAttributes).length == 0) { 171 | return await startGame(intentRequest); 172 | } else if (parseInt(sessionAttributes.currentQuestion, 10) < MAX_QUESTIONS) { 173 | return await nextQuestion(intentRequest); 174 | } else { 175 | return await finishGame(intentRequest); 176 | } 177 | } 178 | 179 | // Route the incoming request based on intent. 180 | // The JSON body of the request is provided in the event slot. 181 | exports.handler = async function(event, context, callback) { 182 | try { 183 | console.log("Request: " + JSON.stringify(event)); 184 | const intentName = event.currentIntent.name; 185 | 186 | if (intentName === 'LetsPlay') { 187 | const response = await playGame(event); 188 | callback(null, response); 189 | } else { 190 | throw new Error(`Intent with name ${intentName} not supported`); 191 | } 192 | } catch (err) { 193 | callback(err); 194 | } 195 | }; 196 | -------------------------------------------------------------------------------- /chat-bot/bot/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trivia-game-chat-bot", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "trivia-game-chat-bot", 9 | "version": "1.0.0", 10 | "dependencies": { 11 | "axios": "^1.9.0", 12 | "number-to-words": "^1.2.4" 13 | } 14 | }, 15 | "node_modules/asynckit": { 16 | "version": "0.4.0", 17 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 18 | "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", 19 | "license": "MIT" 20 | }, 21 | "node_modules/axios": { 22 | "version": "1.9.0", 23 | "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", 24 | "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", 25 | "license": "MIT", 26 | "dependencies": { 27 | "follow-redirects": "^1.15.6", 28 | "form-data": "^4.0.0", 29 | "proxy-from-env": "^1.1.0" 30 | } 31 | }, 32 | "node_modules/combined-stream": { 33 | "version": "1.0.8", 34 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 35 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 36 | "license": "MIT", 37 | "dependencies": { 38 | "delayed-stream": "~1.0.0" 39 | }, 40 | "engines": { 41 | "node": ">= 0.8" 42 | } 43 | }, 44 | "node_modules/delayed-stream": { 45 | "version": "1.0.0", 46 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 47 | "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", 48 | "license": "MIT", 49 | "engines": { 50 | "node": ">=0.4.0" 51 | } 52 | }, 53 | "node_modules/follow-redirects": { 54 | "version": "1.15.9", 55 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", 56 | "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", 57 | "funding": [ 58 | { 59 | "type": "individual", 60 | "url": "https://github.com/sponsors/RubenVerborgh" 61 | } 62 | ], 63 | "license": "MIT", 64 | "engines": { 65 | "node": ">=4.0" 66 | }, 67 | "peerDependenciesMeta": { 68 | "debug": { 69 | "optional": true 70 | } 71 | } 72 | }, 73 | "node_modules/form-data": { 74 | "version": "4.0.1", 75 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", 76 | "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", 77 | "license": "MIT", 78 | "dependencies": { 79 | "asynckit": "^0.4.0", 80 | "combined-stream": "^1.0.8", 81 | "mime-types": "^2.1.12" 82 | }, 83 | "engines": { 84 | "node": ">= 6" 85 | } 86 | }, 87 | "node_modules/mime-db": { 88 | "version": "1.52.0", 89 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 90 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", 91 | "license": "MIT", 92 | "engines": { 93 | "node": ">= 0.6" 94 | } 95 | }, 96 | "node_modules/mime-types": { 97 | "version": "2.1.35", 98 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 99 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 100 | "license": "MIT", 101 | "dependencies": { 102 | "mime-db": "1.52.0" 103 | }, 104 | "engines": { 105 | "node": ">= 0.6" 106 | } 107 | }, 108 | "node_modules/number-to-words": { 109 | "version": "1.2.4", 110 | "resolved": "https://registry.npmjs.org/number-to-words/-/number-to-words-1.2.4.tgz", 111 | "integrity": "sha512-/fYevVkXRcyBiZDg6yzZbm0RuaD6i0qRfn8yr+6D0KgBMOndFPxuW10qCHpzs50nN8qKuv78k8MuotZhcVX6Pw==", 112 | "license": "MIT" 113 | }, 114 | "node_modules/proxy-from-env": { 115 | "version": "1.1.0", 116 | "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", 117 | "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", 118 | "license": "MIT" 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /chat-bot/bot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trivia-game-chat-bot", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "axios": "^1.9.0", 6 | "number-to-words": "^1.2.4" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /chat-bot/buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | runtime-versions: 6 | nodejs: latest 7 | commands: 8 | - cd chat-bot/bot 9 | - npm ci 10 | - cd .. 11 | build: 12 | commands: 13 | - aws cloudformation package --template-file template.yaml --s3-bucket $ARTIFACTS_BUCKET --output-template-file TriviaGameChatBotProd.template.yaml 14 | - sed 's/api.reinvent-trivia.com/api-test.reinvent-trivia.com/g' TriviaGameChatBotProd.template.yaml > TriviaGameChatBotTest.template.yaml 15 | - cp StackConfig.json StackConfigTest.json 16 | - cp StackConfig.json StackConfigProd.json 17 | 18 | artifacts: 19 | files: 20 | - chat-bot/TriviaGameChatBotTest.template.yaml 21 | - chat-bot/TriviaGameChatBotProd.template.yaml 22 | - chat-bot/StackConfigTest.json 23 | - chat-bot/StackConfigProd.json 24 | discard-paths: yes 25 | -------------------------------------------------------------------------------- /chat-bot/hook/pre-traffic-hook.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const aws = require('aws-sdk'); 5 | const codedeploy = new aws.CodeDeploy(); 6 | const lambda = new aws.Lambda(); 7 | 8 | const TARGET_FUNCTION = process.env.CurrentVersion; 9 | 10 | const TESTS = ['intro', 'one', 'four', 'final']; 11 | 12 | exports.handler = async function (event, context, callback) { 13 | 14 | console.log("Entering PreTraffic Hook!"); 15 | console.log(JSON.stringify(event)); 16 | 17 | // Read the DeploymentId from the event payload. 18 | var deploymentId = event.DeploymentId; 19 | console.log("Deployment: " + deploymentId); 20 | 21 | // Read the LifecycleEventHookExecutionId from the event payload 22 | var lifecycleEventHookExecutionId = event.LifecycleEventHookExecutionId; 23 | console.log("LifecycleEventHookExecutionId: " + lifecycleEventHookExecutionId); 24 | 25 | // Prepare the validation test results with the deploymentId and 26 | // the lifecycleEventHookExecutionId for AWS CodeDeploy. 27 | var params = { 28 | deploymentId: deploymentId, 29 | lifecycleEventHookExecutionId: lifecycleEventHookExecutionId, 30 | status: 'Succeeded' 31 | }; 32 | 33 | // Perform validation or pre-warming steps. 34 | // Invoke the function against sample inputs and validate the results. 35 | for (const test of TESTS) { 36 | let testInput = require(`./test-events/${test}.json`); 37 | let testExpectedOutput = require(`./test-events/${test}.expected.json`); 38 | 39 | console.log(`Testing ${test} against ${TARGET_FUNCTION}`); 40 | 41 | try { 42 | let data = await lambda.invoke({ 43 | FunctionName: TARGET_FUNCTION, 44 | Payload: JSON.stringify(testInput) 45 | }).promise(); 46 | let testOutput = JSON.parse(data.Payload); 47 | assert.deepEqual(testOutput, testExpectedOutput, `Unexpected results for ${test}`); 48 | } catch (err) { 49 | console.log(err); 50 | params.status = 'Failed'; 51 | } 52 | } 53 | 54 | // Pass AWS CodeDeploy the prepared validation test results. 55 | try { 56 | console.log(params); 57 | await codedeploy.putLifecycleEventHookExecutionStatus(params).promise(); 58 | console.log('Successfully reported hook results'); 59 | callback(null, 'Successfully reported hook results'); 60 | } catch (err) { 61 | console.log('Failed to report hook results'); 62 | console.log(err); 63 | callback('Failed to report hook results'); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /chat-bot/hook/test-events/final.expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "sessionAttributes": { 3 | "started": "true", 4 | "currentQuestion": "16", 5 | "currentScore": "0", 6 | "currentSlot": "sixteen" 7 | }, 8 | "dialogAction": { 9 | "type": "Close", 10 | "fulfillmentState": "Fulfilled", 11 | "message": { 12 | "contentType": "PlainText", 13 | "content": "That is correct! Thanks for playing! Your final score is 400 points" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /chat-bot/hook/test-events/final.json: -------------------------------------------------------------------------------- 1 | { 2 | "messageVersion": "1.0", 3 | "invocationSource": "DialogCodeHook", 4 | "userId": "ozw40htm8dvj0n1lk9ugt6gja961h4dl", 5 | "sessionAttributes": { 6 | "started": "true", 7 | "currentQuestion": "16", 8 | "currentScore": "0", 9 | "currentSlot": "sixteen" 10 | }, 11 | "requestAttributes": null, 12 | "bot": { 13 | "name": "TriviaGame", 14 | "alias": "$LATEST", 15 | "version": "$LATEST" 16 | }, 17 | "outputDialogMode": "Text", 18 | "currentIntent": { 19 | "name": "LetsPlay", 20 | "slots": { 21 | "fifteen": "blah", 22 | "nine": "blah", 23 | "six": "blah", 24 | "one": 2012, 25 | "seven": "blah", 26 | "three": "blah", 27 | "two": "blah", 28 | "thirteen": "blah", 29 | "eight": "blah", 30 | "fourteen": "blah", 31 | "four": "blah", 32 | "sixteen": "James Hamilton", 33 | "twelve": "blah", 34 | "eleven": "blah", 35 | "ten": "blah", 36 | "five": "blah" 37 | }, 38 | "confirmationStatus": "None" 39 | }, 40 | "inputTranscript": "Let's play" 41 | } 42 | -------------------------------------------------------------------------------- /chat-bot/hook/test-events/four.expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "sessionAttributes": { 3 | "started": "true", 4 | "currentQuestion": 5, 5 | "currentScore": 100, 6 | "currentSlot": "five" 7 | }, 8 | "dialogAction": { 9 | "type": "ElicitSlot", 10 | "intentName": "LetsPlay", 11 | "slots": { 12 | "fifteen": null, 13 | "nine": null, 14 | "six": null, 15 | "one": 2012, 16 | "seven": null, 17 | "three": "blah", 18 | "two": "blah", 19 | "thirteen": null, 20 | "eight": null, 21 | "fourteen": null, 22 | "four": "blah", 23 | "sixteen": null, 24 | "twelve": null, 25 | "eleven": null, 26 | "ten": null, 27 | "five": null 28 | }, 29 | "slotToElicit": "five", 30 | "message": { 31 | "contentType": "PlainText", 32 | "content": "Incorrect! The correct answer is \"2014\". Moving on the \"The Main Event\" category. For 100 points: What webinar helps people learn about re:Invent leading up to the big event?" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /chat-bot/hook/test-events/four.json: -------------------------------------------------------------------------------- 1 | { 2 | "messageVersion": "1.0", 3 | "invocationSource": "DialogCodeHook", 4 | "userId": "ozw40htm8dvj0n1lk9ugt6gja961h4dl", 5 | "sessionAttributes": { 6 | "started": "true", 7 | "currentQuestion": "4", 8 | "currentScore": "100", 9 | "currentSlot": "four" 10 | }, 11 | "requestAttributes": null, 12 | "bot": { 13 | "name": "TriviaGame", 14 | "alias": "$LATEST", 15 | "version": "$LATEST" 16 | }, 17 | "outputDialogMode": "Text", 18 | "currentIntent": { 19 | "name": "LetsPlay", 20 | "slots": { 21 | "fifteen": null, 22 | "nine": null, 23 | "six": null, 24 | "one": 2012, 25 | "seven": null, 26 | "three": "blah", 27 | "two": "blah", 28 | "thirteen": null, 29 | "eight": null, 30 | "fourteen": null, 31 | "four": "blah", 32 | "sixteen": null, 33 | "twelve": null, 34 | "eleven": null, 35 | "ten": null, 36 | "five": null 37 | }, 38 | "confirmationStatus": "None" 39 | }, 40 | "inputTranscript": "Let's play" 41 | } 42 | -------------------------------------------------------------------------------- /chat-bot/hook/test-events/intro.expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "sessionAttributes": { 3 | "started": true, 4 | "currentQuestion": 1, 5 | "currentSlot": "one", 6 | "currentScore": 0 7 | }, 8 | "dialogAction": { 9 | "type": "ElicitSlot", 10 | "intentName": "LetsPlay", 11 | "slots": { 12 | "fifteen": null, 13 | "nine": null, 14 | "six": null, 15 | "one": null, 16 | "seven": null, 17 | "three": null, 18 | "two": null, 19 | "thirteen": null, 20 | "eight": null, 21 | "fourteen": null, 22 | "four": null, 23 | "sixteen": null, 24 | "twelve": null, 25 | "eleven": null, 26 | "ten": null, 27 | "five": null 28 | }, 29 | "slotToElicit": "one", 30 | "message": { 31 | "contentType": "PlainText", 32 | "content": "Let's play re:Invent Trivia! The game covers four categories with four questions each. Starting with the \"Know Your History\" category, for 100 points: What year was the first AWS re:Invent held?" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /chat-bot/hook/test-events/intro.json: -------------------------------------------------------------------------------- 1 | { 2 | "messageVersion": "1.0", 3 | "invocationSource": "DialogCodeHook", 4 | "userId": "ozw40htm8dvj0n1lk9ugt6gja961h4dl", 5 | "sessionAttributes": { 6 | 7 | }, 8 | "requestAttributes": null, 9 | "bot": { 10 | "name": "TriviaGame", 11 | "alias": "$LATEST", 12 | "version": "$LATEST" 13 | }, 14 | "outputDialogMode": "Text", 15 | "currentIntent": { 16 | "name": "LetsPlay", 17 | "slots": { 18 | "fifteen": null, 19 | "nine": null, 20 | "six": null, 21 | "one": null, 22 | "seven": null, 23 | "three": null, 24 | "two": null, 25 | "thirteen": null, 26 | "eight": null, 27 | "fourteen": null, 28 | "four": null, 29 | "sixteen": null, 30 | "twelve": null, 31 | "eleven": null, 32 | "ten": null, 33 | "five": null 34 | }, 35 | "confirmationStatus": "None" 36 | }, 37 | "inputTranscript": "Let's play" 38 | } 39 | -------------------------------------------------------------------------------- /chat-bot/hook/test-events/min.json: -------------------------------------------------------------------------------- 1 | { 2 | "bot": { 3 | "name": "TriviaGame", 4 | "alias": "$LATEST", 5 | "version": "$LATEST" 6 | }, 7 | "currentIntent": { 8 | "name": "LetsPlay", 9 | "slots": { 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /chat-bot/hook/test-events/one.expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "sessionAttributes": { 3 | "started": "true", 4 | "currentQuestion": 2, 5 | "currentScore": 100, 6 | "currentSlot": "two" 7 | }, 8 | "dialogAction": { 9 | "type": "ElicitSlot", 10 | "intentName": "LetsPlay", 11 | "slots": { 12 | "fifteen": null, 13 | "nine": null, 14 | "six": null, 15 | "one": 2012, 16 | "seven": null, 17 | "three": null, 18 | "two": null, 19 | "thirteen": null, 20 | "eight": null, 21 | "fourteen": null, 22 | "four": null, 23 | "sixteen": null, 24 | "twelve": null, 25 | "eleven": null, 26 | "ten": null, 27 | "five": null 28 | }, 29 | "slotToElicit": "two", 30 | "message": { 31 | "contentType": "PlainText", 32 | "content": "That is correct! The answer is \"2012\". New score is 100 points! For 200 points: What Amazon-favorite sport was introduced to re:Invent attendees in 2016?" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /chat-bot/hook/test-events/one.json: -------------------------------------------------------------------------------- 1 | { 2 | "messageVersion": "1.0", 3 | "invocationSource": "DialogCodeHook", 4 | "userId": "ozw40htm8dvj0n1lk9ugt6gja961h4dl", 5 | "sessionAttributes": { 6 | "started": "true", 7 | "currentQuestion": "1", 8 | "currentScore": "0", 9 | "currentSlot": "one" 10 | }, 11 | "requestAttributes": null, 12 | "bot": { 13 | "name": "TriviaGame", 14 | "alias": "$LATEST", 15 | "version": "$LATEST" 16 | }, 17 | "outputDialogMode": "Text", 18 | "currentIntent": { 19 | "name": "LetsPlay", 20 | "slots": { 21 | "fifteen": null, 22 | "nine": null, 23 | "six": null, 24 | "one": 2012, 25 | "seven": null, 26 | "three": null, 27 | "two": null, 28 | "thirteen": null, 29 | "eight": null, 30 | "fourteen": null, 31 | "four": null, 32 | "sixteen": null, 33 | "twelve": null, 34 | "eleven": null, 35 | "ten": null, 36 | "five": null 37 | }, 38 | "confirmationStatus": "None" 39 | }, 40 | "inputTranscript": "Let's play" 41 | } 42 | -------------------------------------------------------------------------------- /chat-bot/lex-model/.gitignore: -------------------------------------------------------------------------------- 1 | lex-model.json 2 | lex-model.zip 3 | -------------------------------------------------------------------------------- /chat-bot/lex-model/buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | runtime-versions: 6 | nodejs: latest 7 | commands: 8 | - export AWS_ACCOUNT_ID=`echo $CODEBUILD_BUILD_ARN | awk -F":" '{print $5}'` 9 | - cd chat-bot/lex-model 10 | - chmod +x gen-lex-model.sh 11 | - npm ci 12 | build: 13 | commands: 14 | - ./gen-lex-model.sh 15 | -------------------------------------------------------------------------------- /chat-bot/lex-model/convert-model.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const yargs = require('yargs'); 4 | 5 | const argv = yargs(process.argv.slice(2)) 6 | .usage('Convert trivia API file to Amazon Lex model\nUsage: $0') 7 | .demandOption(['m', 'f']) 8 | .alias('m', 'api-model') 9 | .alias('f', 'hook-function') 10 | .describe('m', 'trivia API file') 11 | .describe('f', 'Lambda function ARN for fulfillment activity') 12 | .parse(); 13 | 14 | const converter = require('number-to-words'); 15 | const fs = require('fs'); 16 | 17 | const STRING_API_TYPE = 'STRING'; 18 | const NUMBER_API_TYPE = 'NUMBER'; 19 | const NUMBER_MODEL_TYPE = 'AMAZON.NUMBER'; 20 | 21 | // Assume it's a JSON file 22 | const apiModel = require(argv.apiModel); 23 | const lexModel = require('./lex-model-template'); 24 | 25 | const hookFunction = argv.hookFunction; 26 | 27 | // Convert API model to Lex model 28 | // API model question ==> Lex Slot 29 | // API model answer ==> Lex Slot Type 30 | apiModel.forEach(function(category) { 31 | let categoryName = category.category; 32 | category.questions.forEach(function(question) { 33 | let slotName = converter.toWords(question.id); 34 | 35 | let slot = { 36 | name: slotName, 37 | slotConstraint: "Optional", // Let the Lambda function drive the order of conversation 38 | valueElicitationPrompt: { 39 | messages: [ 40 | { 41 | "contentType": "PlainText", 42 | "content": question.question 43 | } 44 | ], 45 | maxAttempts: 2 46 | }, 47 | priority: question.id, 48 | sampleUtterances: [] // TODO what is this?? 49 | }; 50 | 51 | if (question.answerType == NUMBER_API_TYPE) { 52 | slot.slotType = NUMBER_MODEL_TYPE; 53 | } else if (question.answerType == STRING_API_TYPE) { 54 | let slotTypeName = `Type${slotName}`; 55 | let slotType = { 56 | name: slotTypeName, 57 | version: "1", 58 | enumerationValues: [ 59 | { 60 | value: question.answer, 61 | synonyms: question.alternativeAnswers 62 | } 63 | ], 64 | valueSelectionStrategy: 'TOP_RESOLUTION' 65 | }; 66 | lexModel.resource.slotTypes.push(slotType); 67 | 68 | slot.slotType = slotTypeName; 69 | slot.slotTypeVersion = "1"; 70 | } else { 71 | console.log(`Unrecognized answer type: ${question.answerType}`); 72 | process.exit(1); 73 | } 74 | 75 | lexModel.resource.intents[0].slots.push(slot); 76 | }); 77 | }); 78 | 79 | lexModel.resource.intents[0].fulfillmentActivity.codeHook.uri = hookFunction; 80 | 81 | // TODO fill in code hook ARNs 82 | 83 | fs.writeFileSync('./lex-model.json', JSON.stringify(lexModel, null, 2) , 'utf-8'); 84 | -------------------------------------------------------------------------------- /chat-bot/lex-model/gen-lex-model.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | # Generate the model 6 | lambda_function_name=$(aws cloudformation describe-stack-resource --stack-name TriviaGameChatBotProd --logical-resource-id BotFunction --output text --query 'StackResourceDetail.PhysicalResourceId') 7 | 8 | lambda_function_arn="arn:aws:lambda:$AWS_DEFAULT_REGION:$AWS_ACCOUNT_ID:function:$lambda_function_name:live" 9 | 10 | node convert-model -m ../../trivia-backend/data/questions.json -f $lambda_function_arn 11 | 12 | zip lex-model.zip lex-model.json 13 | 14 | # Import the model 15 | import_id=$(aws lex-models start-import \ 16 | --payload fileb://lex-model.zip \ 17 | --resource-type BOT \ 18 | --merge-strategy OVERWRITE_LATEST \ 19 | --output text \ 20 | --query 'importId') 21 | 22 | while state=$(aws lex-models get-import --import-id $import_id --output text --query 'importStatus'); test "$state" = "IN_PROGRESS"; do 23 | sleep 1; echo -n '.' 24 | done; 25 | 26 | aws lex-models get-import --import-id $import_id 27 | 28 | state=$(aws lex-models get-import --import-id $import_id --output text --query 'importStatus') 29 | 30 | test "$state" = "COMPLETE" 31 | 32 | # Build the model 33 | aws lex-models put-bot-alias --name Prod --bot-name TriviaGame --bot-version "\$LATEST" || true 34 | 35 | checksum=$(aws lex-models get-bot --name TriviaGame --version-or-alias "\$LATEST" --query 'checksum' --output text) 36 | 37 | aws lex-models put-bot --name TriviaGame --cli-input-json file://trivia-game-bot.json --checksum $checksum 38 | 39 | while state=$(aws lex-models get-bot --name TriviaGame --version-or-alias "\$LATEST" --output text --query 'status'); test "$state" = "BUILDING" || test "$state" = "READY_BASIC_TESTING"; do 40 | sleep 1; echo -n '.' 41 | done; 42 | 43 | aws lex-models get-bot --name TriviaGame --version-or-alias "\$LATEST" 44 | 45 | state=$(aws lex-models get-bot --name TriviaGame --version-or-alias "\$LATEST" --output text --query 'status') 46 | 47 | test "$state" = "READY" 48 | -------------------------------------------------------------------------------- /chat-bot/lex-model/lex-model-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": { 3 | "schemaVersion": "1.0", 4 | "importType": "LEX", 5 | "importFormat": "JSON" 6 | }, 7 | "resource": { 8 | "name": "TriviaGame", 9 | "version": "1", 10 | "intents": [ 11 | { 12 | "name": "LetsPlay", 13 | "version": "1", 14 | "fulfillmentActivity": { 15 | "type": "CodeHook", 16 | "codeHook": { 17 | "uri": "arn:aws:lambda:us-west-2:123456789012:function:helloworld", 18 | "messageVersion": "1.0" 19 | } 20 | }, 21 | "sampleUtterances": [ 22 | "Let's play", 23 | "Start the game", 24 | "Ask me another question", 25 | "I want to play the trivia game" 26 | ], 27 | "slots": [ 28 | ] 29 | } 30 | ], 31 | "slotTypes": [ 32 | ], 33 | "voiceId": "0", 34 | "childDirected": false, 35 | "locale": "en-US", 36 | "idleSessionTTLInSeconds": 300, 37 | "clarificationPrompt": { 38 | "messages": [ 39 | { 40 | "contentType": "PlainText", 41 | "content": "Sorry, can you please repeat that?" 42 | } 43 | ], 44 | "maxAttempts": 5 45 | }, 46 | "abortStatement": { 47 | "messages": [ 48 | { 49 | "contentType": "PlainText", 50 | "content": "Sorry, I could not understand. Goodbye." 51 | } 52 | ] 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /chat-bot/lex-model/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lex-model", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "lex-model", 9 | "version": "1.0.0", 10 | "dependencies": { 11 | "async": "^3.2.6", 12 | "number-to-words": "^1.2.4", 13 | "yargs": "^18.0.0" 14 | }, 15 | "bin": { 16 | "convert-model": "convert-model.js" 17 | } 18 | }, 19 | "node_modules/ansi-regex": { 20 | "version": "6.1.0", 21 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", 22 | "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", 23 | "license": "MIT", 24 | "engines": { 25 | "node": ">=12" 26 | }, 27 | "funding": { 28 | "url": "https://github.com/chalk/ansi-regex?sponsor=1" 29 | } 30 | }, 31 | "node_modules/ansi-styles": { 32 | "version": "6.2.1", 33 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", 34 | "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", 35 | "license": "MIT", 36 | "engines": { 37 | "node": ">=12" 38 | }, 39 | "funding": { 40 | "url": "https://github.com/chalk/ansi-styles?sponsor=1" 41 | } 42 | }, 43 | "node_modules/async": { 44 | "version": "3.2.6", 45 | "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", 46 | "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", 47 | "license": "MIT" 48 | }, 49 | "node_modules/cliui": { 50 | "version": "9.0.1", 51 | "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", 52 | "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", 53 | "license": "ISC", 54 | "dependencies": { 55 | "string-width": "^7.2.0", 56 | "strip-ansi": "^7.1.0", 57 | "wrap-ansi": "^9.0.0" 58 | }, 59 | "engines": { 60 | "node": ">=20" 61 | } 62 | }, 63 | "node_modules/emoji-regex": { 64 | "version": "10.4.0", 65 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", 66 | "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", 67 | "license": "MIT" 68 | }, 69 | "node_modules/escalade": { 70 | "version": "3.2.0", 71 | "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", 72 | "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", 73 | "license": "MIT", 74 | "engines": { 75 | "node": ">=6" 76 | } 77 | }, 78 | "node_modules/get-caller-file": { 79 | "version": "2.0.5", 80 | "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", 81 | "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", 82 | "license": "ISC", 83 | "engines": { 84 | "node": "6.* || 8.* || >= 10.*" 85 | } 86 | }, 87 | "node_modules/get-east-asian-width": { 88 | "version": "1.3.0", 89 | "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", 90 | "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", 91 | "license": "MIT", 92 | "engines": { 93 | "node": ">=18" 94 | }, 95 | "funding": { 96 | "url": "https://github.com/sponsors/sindresorhus" 97 | } 98 | }, 99 | "node_modules/number-to-words": { 100 | "version": "1.2.4", 101 | "resolved": "https://registry.npmjs.org/number-to-words/-/number-to-words-1.2.4.tgz", 102 | "integrity": "sha512-/fYevVkXRcyBiZDg6yzZbm0RuaD6i0qRfn8yr+6D0KgBMOndFPxuW10qCHpzs50nN8qKuv78k8MuotZhcVX6Pw==", 103 | "license": "MIT" 104 | }, 105 | "node_modules/string-width": { 106 | "version": "7.2.0", 107 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", 108 | "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", 109 | "license": "MIT", 110 | "dependencies": { 111 | "emoji-regex": "^10.3.0", 112 | "get-east-asian-width": "^1.0.0", 113 | "strip-ansi": "^7.1.0" 114 | }, 115 | "engines": { 116 | "node": ">=18" 117 | }, 118 | "funding": { 119 | "url": "https://github.com/sponsors/sindresorhus" 120 | } 121 | }, 122 | "node_modules/strip-ansi": { 123 | "version": "7.1.0", 124 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", 125 | "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", 126 | "license": "MIT", 127 | "dependencies": { 128 | "ansi-regex": "^6.0.1" 129 | }, 130 | "engines": { 131 | "node": ">=12" 132 | }, 133 | "funding": { 134 | "url": "https://github.com/chalk/strip-ansi?sponsor=1" 135 | } 136 | }, 137 | "node_modules/wrap-ansi": { 138 | "version": "9.0.0", 139 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", 140 | "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", 141 | "license": "MIT", 142 | "dependencies": { 143 | "ansi-styles": "^6.2.1", 144 | "string-width": "^7.0.0", 145 | "strip-ansi": "^7.1.0" 146 | }, 147 | "engines": { 148 | "node": ">=18" 149 | }, 150 | "funding": { 151 | "url": "https://github.com/chalk/wrap-ansi?sponsor=1" 152 | } 153 | }, 154 | "node_modules/y18n": { 155 | "version": "5.0.8", 156 | "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", 157 | "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", 158 | "license": "ISC", 159 | "engines": { 160 | "node": ">=10" 161 | } 162 | }, 163 | "node_modules/yargs": { 164 | "version": "18.0.0", 165 | "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", 166 | "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", 167 | "license": "MIT", 168 | "dependencies": { 169 | "cliui": "^9.0.1", 170 | "escalade": "^3.1.1", 171 | "get-caller-file": "^2.0.5", 172 | "string-width": "^7.2.0", 173 | "y18n": "^5.0.5", 174 | "yargs-parser": "^22.0.0" 175 | }, 176 | "engines": { 177 | "node": "^20.19.0 || ^22.12.0 || >=23" 178 | } 179 | }, 180 | "node_modules/yargs-parser": { 181 | "version": "22.0.0", 182 | "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", 183 | "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", 184 | "license": "ISC", 185 | "engines": { 186 | "node": "^20.19.0 || ^22.12.0 || >=23" 187 | } 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /chat-bot/lex-model/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lex-model", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "convert-model.js", 6 | "bin": { 7 | "convert-model": "convert-model.js" 8 | }, 9 | "dependencies": { 10 | "async": "^3.2.6", 11 | "number-to-words": "^1.2.4", 12 | "yargs": "^18.0.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /chat-bot/lex-model/trivia-game-bot.json: -------------------------------------------------------------------------------- 1 | { 2 | "intents": [ 3 | { 4 | "intentVersion": "1", 5 | "intentName": "LetsPlay" 6 | } 7 | ], 8 | "name": "TriviaGame", 9 | "locale": "en-US", 10 | "checksum": "###checksum###", 11 | "abortStatement": { 12 | "messages": [ 13 | { 14 | "content": "Sorry, I could not understand.", 15 | "contentType": "PlainText" 16 | } 17 | ] 18 | }, 19 | "clarificationPrompt": { 20 | "maxAttempts": 5, 21 | "messages": [ 22 | { 23 | "content": "Sorry, can you please repeat that?", 24 | "contentType": "PlainText" 25 | } 26 | ] 27 | }, 28 | "childDirected": false, 29 | "idleSessionTTLInSeconds": 300 30 | } 31 | -------------------------------------------------------------------------------- /chat-bot/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: Chat bot for reInvent trivia game 4 | 5 | Parameters: 6 | TriviaBackendEndpoint: 7 | Type: String 8 | Default: 'https://api.reinvent-trivia.com' 9 | 10 | Resources: 11 | BotFunction: 12 | Type: AWS::Serverless::Function 13 | Properties: 14 | CodeUri: ./bot 15 | Handler: bot.handler 16 | Runtime: nodejs14.x 17 | Timeout: 60 18 | Environment: 19 | Variables: 20 | API_ENDPOINT: !Ref TriviaBackendEndpoint 21 | Events: 22 | Canary: 23 | Type: Schedule 24 | Properties: 25 | Schedule: rate(1 minute) 26 | Input: >- 27 | { 28 | "bot": { 29 | "name": "TriviaGame", 30 | "alias": "$LATEST", 31 | "version": "$LATEST" 32 | }, 33 | "currentIntent": { 34 | "name": "LetsPlay", 35 | "slots": { 36 | } 37 | } 38 | } 39 | AutoPublishAlias: live 40 | DeploymentPreference: 41 | Enabled: true 42 | Type: Canary10Percent5Minutes 43 | Alarms: 44 | - !Ref BotLatestVersionErrorMetricGreaterThanZeroAlarm 45 | - !Ref BotAliasErrorMetricGreaterThanZeroAlarm 46 | Hooks: 47 | PreTraffic: !Ref PreTrafficHook 48 | 49 | BotAliasErrorMetricGreaterThanZeroAlarm: 50 | Type: "AWS::CloudWatch::Alarm" 51 | Properties: 52 | AlarmName: !Sub ${AWS::StackName}-BotAliasErrors 53 | AlarmDescription: Lambda Function Error > 0 54 | ComparisonOperator: GreaterThanThreshold 55 | Dimensions: 56 | - Name: Resource 57 | Value: !Sub "${BotFunction}:live" 58 | - Name: FunctionName 59 | Value: !Ref BotFunction 60 | EvaluationPeriods: 2 61 | MetricName: Errors 62 | Namespace: AWS/Lambda 63 | Period: 60 64 | Statistic: Sum 65 | Threshold: 0 66 | 67 | BotLatestVersionErrorMetricGreaterThanZeroAlarm: 68 | Type: "AWS::CloudWatch::Alarm" 69 | Properties: 70 | AlarmName: !Sub ${AWS::StackName}-BotLatestVersionErrors 71 | AlarmDescription: Lambda Function Error > 0 72 | ComparisonOperator: GreaterThanThreshold 73 | Dimensions: 74 | - Name: Resource 75 | Value: !Sub "${BotFunction}:live" 76 | - Name: FunctionName 77 | Value: !Ref BotFunction 78 | - Name: ExecutedVersion 79 | Value: !GetAtt BotFunction.Version.Version 80 | EvaluationPeriods: 2 81 | MetricName: Errors 82 | Namespace: AWS/Lambda 83 | Period: 60 84 | Statistic: Sum 85 | Threshold: 0 86 | 87 | PreTrafficHook: 88 | Type: AWS::Serverless::Function 89 | Properties: 90 | FunctionName: !Join 91 | - '-' 92 | - - 'CodeDeployHook_' 93 | - !Ref "AWS::StackName" 94 | - 'pre-traffic-hook' 95 | CodeUri: ./hook 96 | Timeout: 300 97 | Handler: pre-traffic-hook.handler 98 | Policies: 99 | - Version: "2012-10-17" 100 | Statement: 101 | - Effect: "Allow" 102 | Action: 103 | - "codedeploy:PutLifecycleEventHookExecutionStatus" 104 | Resource: 105 | !Sub 'arn:aws:codedeploy:${AWS::Region}:${AWS::AccountId}:deploymentgroup:${ServerlessDeploymentApplication}/*' 106 | - Version: "2012-10-17" 107 | Statement: 108 | - Effect: "Allow" 109 | Action: 110 | - "lambda:InvokeFunction" 111 | Resource: !Sub "${BotFunction.Arn}:*" 112 | Runtime: nodejs14.x 113 | DeploymentPreference: 114 | Enabled: false 115 | Role: "" 116 | Environment: 117 | Variables: 118 | CurrentVersion: !Ref BotFunction.Version 119 | 120 | LexPermission: 121 | Type: AWS::Lambda::Permission 122 | Properties: 123 | FunctionName: !Ref BotFunction.Alias 124 | Action: "lambda:invokeFunction" 125 | Principal: lex.amazonaws.com 126 | SourceArn: !Join 127 | - ':' 128 | - - 'arn' 129 | - !Ref "AWS::Partition" 130 | - 'lex' 131 | - !Ref "AWS::Region" 132 | - !Ref "AWS::AccountId" 133 | - 'intent:LetsPlay:*' 134 | -------------------------------------------------------------------------------- /pipelines/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.d.ts 3 | *.swp 4 | node_modules 5 | cdk.json 6 | cdk.context.json 7 | cdk.out 8 | -------------------------------------------------------------------------------- /pipelines/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | -------------------------------------------------------------------------------- /pipelines/README.md: -------------------------------------------------------------------------------- 1 | # Continuous delivery pipelines 2 | 3 | This package uses the [AWS Cloud Development Kit (AWS)](https://github.com/awslabs/aws-cdk) to model AWS CodePipeline pipelines and to provision them with AWS CloudFormation. 4 | 5 | In [src](src/) directory: 6 | * pipeline.ts: Generic pipeline class that defines an infrastructure-as-code pipeline 7 | * api-base-image-pipeline.ts: Builds and publishes the base Docker image for the backend API service 8 | * api-service-pipeline.ts: Builds and deploys the backend API service to Fargate using CodePipeline's [CloudFormation deploy actions](https://docs.aws.amazon.com/codepipeline/latest/userguide/integrations-action-type.html#integrations-deploy-CloudFormation) 9 | * api-service-blue-green-pipeline.ts: Builds and deploys the backend API service to Fargate using CodePipeline's [CloudFormation deploy actions](https://docs.aws.amazon.com/codepipeline/latest/userguide/integrations-action-type.html#integrations-deploy-CloudFormation) and CloudFormation's integration with CodeDeploy blue-green deployments 10 | * api-service-codedeploy-pipeline.ts: Builds and deploys the backend API service to Fargate using CodePipeline's ["ECS (Blue/Green)" deploy action](https://docs.aws.amazon.com/codepipeline/latest/userguide/integrations-action-type.html#integrations-deploy-ECS) 11 | * api-service-codedeploy-lifecycle-event-hooks-pipeline.ts: Builds and deploys the CodeDeploy lifecycle event hooks that test the backend API service during a CodeDeploy deployment 12 | * static-site-pipeline.ts: Provisions infrastructure for the static site, like a CloudFront distribution and an S3 bucket, plus bundles and uploads the static site pages to the site's S3 bucket 13 | * chat-bot-pipeline.ts: Builds and deploys the chat bot Lambda function and Lex model 14 | * canaries-pipeline.ts: Builds and deploys the monitoring canaries 15 | * pipelines-bootstrap.ts: Creates resources used by all the pipelines, like a CodeStar Connections connection. 16 | 17 | ## Prep 18 | 19 | Create an SNS topic for notifications about pipeline execution failures. An email address or a [chat bot](https://docs.aws.amazon.com/chatbot/latest/adminguide/setting-up.html) can be subscribed to the topic to receive notifications about pipeline failures. 20 | ``` 21 | aws sns create-topic --name reinvent-trivia-notifications --tags Key=project,Value=reinvent-trivia --region us-east-1 22 | ``` 23 | 24 | Follow the [CodeStar Notifications user guide](https://docs.aws.amazon.com/codestar-notifications/latest/userguide/set-up-sns.html) to configure the SNS topic to be able to receive notifications about pipeline failures. 25 | 26 | ## Customize 27 | 28 | Replace all references to 'aws-samples' with your own fork of this repo. Replace all references to 'reinvent-trivia.com' with your own domain name. 29 | 30 | ## Deploy 31 | 32 | Install the AWS CDK CLI: `npm i -g aws-cdk` 33 | 34 | Install and build everything: `npm install && npm run build` 35 | 36 | Deploy common resources used by all the pipelines: 37 | 38 | ``` 39 | cdk deploy --app 'node src/pipelines-bootstrap.js' 40 | ``` 41 | 42 | Activate the CodeStar Connections connection created by the previous step. Go to the [CodeStar Connections console](https://console.aws.amazon.com/codesuite/settings/connections?region=us-east-1), select the `reinvent-trivia-repo` connection, and click "Update pending connection". Then follow the prompts to connect your GitHub account and repos to AWS. When finished, the `reinvent-trivia-repo` connection should have the "Available" status. 43 | 44 | Then, deploy the individual pipeline stacks: 45 | 46 | ``` 47 | cdk deploy --app 'node src/static-site-pipeline.js' 48 | 49 | cdk deploy --app 'node src/api-base-image-pipeline.js' 50 | 51 | cdk deploy --app 'node src/api-service-pipeline.js' 52 | OR 53 | cdk deploy --app 'node src/api-service-blue-green-pipeline.js' 54 | OR 55 | cdk deploy --app 'node src/api-service-codedeploy-pipeline.js' 56 | 57 | cdk deploy --app 'node src/api-service-codedeploy-lifecycle-event-hooks-pipeline.js' 58 | 59 | cdk deploy --app 'node src/chat-bot-pipeline.js' 60 | 61 | cdk deploy --app 'node src/canaries-pipeline.js' 62 | ``` 63 | 64 | See the pipelines in the CodePipeline console. 65 | -------------------------------------------------------------------------------- /pipelines/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trivia-game-pipelines", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "build": "tsc", 6 | "watch": "tsc -w", 7 | "synth-static-site-pipeline": "cdk synth -o build --app 'node src/static-site-pipeline.js'", 8 | "deploy-static-site-pipeline": "cdk deploy --app 'node src/static-site-pipeline.js'", 9 | "synth-backend-pipeline": "cdk synth -o build --app 'node src/api-service-pipeline.js'", 10 | "deploy-backend-pipeline": "cdk deploy --app 'node src/api-service-pipeline.js'", 11 | "synth-backend-blue-green-pipeline": "cdk synth -o build --app 'node src/api-service-blue-green-pipeline.js'", 12 | "deploy-backend-blue-green-pipeline": "cdk deploy --app 'node src/api-service-blue-green-pipeline.js'", 13 | "synth-backend-codedeploy-pipeline": "cdk synth -o build --app 'node src/api-service-codedeploy-pipeline.js'", 14 | "deploy-backend-codedeploy-pipeline": "cdk deploy --app 'node src/api-service-codedeploy-pipeline.js'", 15 | "synth-backend-base-image-pipeline": "cdk synth -o build --app 'node src/api-base-image-pipeline.js'", 16 | "deploy-backend-base-image-pipeline": "cdk deploy --app 'node src/api-base-image-pipeline.js'", 17 | "synth-chat-bot-pipeline": "cdk synth -o build --app 'node src/chat-bot-pipeline.js'", 18 | "deploy-chat-bot-pipeline": "cdk deploy --app 'node src/chat-bot-pipeline.js'", 19 | "synth-canaries-pipeline": "cdk synth -o build --app 'node src/canaries-pipeline.js'", 20 | "deploy-canaries-pipeline": "cdk deploy --app 'node src/canaries-pipeline.js'", 21 | "synth-lifecycle-hooks-pipeline": "cdk synth -o build --app 'node src/api-service-codedeploy-lifecycle-event-hooks-pipeline.js'", 22 | "deploy-lifecycle-hooks-pipeline": "cdk deploy --app 'node src/api-service-codedeploy-lifecycle-event-hooks-pipeline.js'", 23 | "synth-pipelines-bootstrap": "cdk synth -o build --app 'node src/pipelines-bootstrap.js'", 24 | "deploy-pipelines-bootstrap": "cdk deploy --app 'node src/pipelines-bootstrap.js'" 25 | }, 26 | "devDependencies": { 27 | "@types/node": "^22.15.29", 28 | "typescript": "^5.8.3", 29 | "aws-cdk": "^2.1017.1" 30 | }, 31 | "dependencies": { 32 | "aws-cdk-lib": "^2.199.0", 33 | "constructs": "^10.4.2" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /pipelines/src/api-base-image-pipeline.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { App, Fn, Stack, StackProps } from 'aws-cdk-lib'; 3 | import { 4 | aws_codebuild as codebuild, 5 | aws_codepipeline as codepipeline, 6 | aws_codestarnotifications as notifications, 7 | aws_codepipeline_actions as actions, 8 | aws_iam as iam, 9 | } from 'aws-cdk-lib'; 10 | 11 | /** 12 | * Simple two-stage pipeline to build the base image for the trivia game backend service. 13 | * [GitHub source] -> [CodeBuild build, pushes image to ECR] 14 | */ 15 | class TriviaGameBackendBaseImagePipeline extends Stack { 16 | constructor(parent: App, name: string, props?: StackProps) { 17 | super(parent, name, props); 18 | 19 | const pipeline = new codepipeline.Pipeline(this, 'Pipeline', { 20 | pipelineName: 'reinvent-trivia-game-base-image', 21 | restartExecutionOnUpdate: true, 22 | }); 23 | 24 | new notifications.CfnNotificationRule(this, 'PipelineNotifications', { 25 | name: pipeline.pipelineName, 26 | detailType: 'FULL', 27 | resource: pipeline.pipelineArn, 28 | eventTypeIds: [ 'codepipeline-pipeline-pipeline-execution-failed' ], 29 | targets: [ 30 | { 31 | targetType: 'SNS', 32 | targetAddress: Stack.of(this).formatArn({ 33 | service: 'sns', 34 | resource: 'reinvent-trivia-notifications' 35 | }), 36 | } 37 | ] 38 | }); 39 | 40 | // Source 41 | const githubConnection = Fn.importValue('TriviaGamePipelinesCodeStarConnection'); 42 | const sourceOutput = new codepipeline.Artifact('SourceArtifact'); 43 | const sourceAction = new actions.CodeStarConnectionsSourceAction({ 44 | actionName: 'GitHubSource', 45 | owner: 'aws-samples', 46 | repo: 'aws-reinvent-trivia-game', 47 | connectionArn: githubConnection, 48 | output: sourceOutput 49 | }); 50 | pipeline.addStage({ 51 | stageName: 'Source', 52 | actions: [sourceAction], 53 | }); 54 | 55 | // Update pipeline 56 | // This pipeline stage uses CodeBuild to self-mutate the pipeline by re-deploying the pipeline's CDK code 57 | // If the pipeline changes, it will automatically start again 58 | const pipelineProject = new codebuild.PipelineProject(this, "UpdatePipeline", { 59 | buildSpec: codebuild.BuildSpec.fromObjectToYaml({ 60 | version: '0.2', 61 | phases: { 62 | install: { 63 | 'runtime-versions': { 64 | nodejs: 'latest', 65 | }, 66 | commands: [ 67 | 'npm install -g aws-cdk', 68 | ], 69 | }, 70 | build: { 71 | commands: [ 72 | 'cd $CODEBUILD_SRC_DIR/pipelines', 73 | 'npm ci', 74 | 'npm run build', 75 | "cdk deploy --app 'node src/api-base-image-pipeline.js' --require-approval=never", 76 | ] 77 | }, 78 | }, 79 | }), 80 | environment: { 81 | buildImage: codebuild.LinuxBuildImage.AMAZON_LINUX_2_5, 82 | }, 83 | }); 84 | pipelineProject.addToRolePolicy( 85 | new iam.PolicyStatement({ 86 | actions: [ 87 | "cloudformation:*", 88 | "codebuild:*", 89 | "codepipeline:*", 90 | "s3:*", 91 | "kms:*", 92 | "codestar-notifications:*", 93 | "codestar-connections:*", 94 | "iam:*", 95 | "events:*", 96 | "ssm:*", 97 | ], 98 | resources: ["*"], 99 | }) 100 | ); 101 | const pipelineBuildAction = new actions.CodeBuildAction({ 102 | actionName: 'DeployPipeline', 103 | project: pipelineProject, 104 | input: sourceOutput, 105 | }); 106 | pipeline.addStage({ 107 | stageName: 'SyncPipeline', 108 | actions: [pipelineBuildAction], 109 | }); 110 | 111 | // Build 112 | const project = new codebuild.PipelineProject(this, 'BuildBaseImage', { 113 | buildSpec: codebuild.BuildSpec.fromSourceFilename('trivia-backend/base/buildspec.yml'), 114 | environment: { 115 | buildImage: codebuild.LinuxBuildImage.AMAZON_LINUX_2_5, 116 | privileged: true 117 | } 118 | }); 119 | project.addToRolePolicy(new iam.PolicyStatement({ 120 | actions: ["ecr:GetAuthorizationToken", 121 | "ecr:BatchCheckLayerAvailability", 122 | "ecr:GetDownloadUrlForLayer", 123 | "ecr:GetRepositoryPolicy", 124 | "ecr:DescribeRepositories", 125 | "ecr:ListImages", 126 | "ecr:DescribeImages", 127 | "ecr:BatchGetImage", 128 | "ecr:InitiateLayerUpload", 129 | "ecr:UploadLayerPart", 130 | "ecr:CompleteLayerUpload", 131 | "ecr:PutImage" 132 | ], 133 | resources: ["*"] 134 | })); 135 | 136 | const buildAction = new actions.CodeBuildAction({ 137 | actionName: 'CodeBuild', 138 | project, 139 | input: sourceOutput 140 | }); 141 | 142 | pipeline.addStage({ 143 | stageName: 'Build', 144 | actions: [buildAction] 145 | }); 146 | } 147 | } 148 | 149 | const app = new App(); 150 | new TriviaGameBackendBaseImagePipeline(app, 'TriviaGameBackendBaseImagePipeline', { 151 | env: { account: process.env['CDK_DEFAULT_ACCOUNT'], region: 'us-east-1' }, 152 | tags: { 153 | project: "reinvent-trivia" 154 | } 155 | }); 156 | app.synth(); 157 | -------------------------------------------------------------------------------- /pipelines/src/api-service-blue-green-pipeline.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { App, Stack, StackProps } from 'aws-cdk-lib'; 3 | import { TriviaGameContainersCfnPipeline } from './common/cfn-containers-pipeline'; 4 | 5 | /** 6 | * Pipeline that builds a container image and deploys it to ECS using CloudFormation and CodeDeploy blue-green deployments. 7 | * [Sources: GitHub source, ECR base image] -> [CodeBuild build] -> [CloudFormation Deploy Actions to 'test' stack] -> [CloudFormation Deploy Actions to 'prod' stack] 8 | */ 9 | class TriviaGameBackendBlueGreenPipelineStack extends Stack { 10 | constructor(parent: App, name: string, props?: StackProps) { 11 | super(parent, name, props); 12 | 13 | new TriviaGameContainersCfnPipeline(this, 'Pipeline', { 14 | pipelineNameSuffix: 'trivia-backend-cfn-blue-green-deploy', 15 | stackNamePrefix: 'TriviaBackend', 16 | templateNamePrefix: 'TriviaBackend', 17 | buildspecLocation: 'trivia-backend/infra/cdk/buildspec-blue-green.yml', 18 | pipelineCdkFileName: 'api-service-blue-green-pipeline', 19 | }); 20 | } 21 | } 22 | 23 | const app = new App(); 24 | new TriviaGameBackendBlueGreenPipelineStack(app, 'TriviaGameBackendBlueGreenPipeline', { 25 | env: { account: process.env['CDK_DEFAULT_ACCOUNT'], region: 'us-east-1' }, 26 | tags: { 27 | project: 'reinvent-trivia' 28 | } 29 | }); 30 | app.synth(); -------------------------------------------------------------------------------- /pipelines/src/api-service-codedeploy-lifecycle-event-hooks-pipeline.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { App, Stack, StackProps } from 'aws-cdk-lib'; 3 | import { TriviaGameCfnPipeline } from './common/cfn-pipeline'; 4 | 5 | class TriviaGameLifecycleHooksPipelineStack extends Stack { 6 | constructor(parent: App, name: string, props?: StackProps) { 7 | super(parent, name, props); 8 | 9 | new TriviaGameCfnPipeline(this, 'Pipeline', { 10 | pipelineName: 'codedeploy-lifecycle-event-hooks', 11 | stackName: 'Hooks', 12 | stackNamePrefix: 'TriviaBackend', 13 | templateName: 'Hooks', 14 | directory: 'trivia-backend/infra/codedeploy-lifecycle-event-hooks', 15 | pipelineCdkFileName: 'api-service-codedeploy-lifecycle-event-hooks-pipeline', 16 | }); 17 | } 18 | } 19 | 20 | const app = new App(); 21 | new TriviaGameLifecycleHooksPipelineStack(app, 'TriviaGameLifecycleHooksPipeline', { 22 | env: { account: process.env['CDK_DEFAULT_ACCOUNT'], region: 'us-east-1' }, 23 | tags: { 24 | project: "reinvent-trivia" 25 | } 26 | }); 27 | app.synth(); -------------------------------------------------------------------------------- /pipelines/src/api-service-pipeline.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { App, Stack, StackProps } from 'aws-cdk-lib'; 3 | import { TriviaGameContainersCfnPipeline } from './common/cfn-containers-pipeline'; 4 | 5 | /** 6 | * Pipeline that builds a container image and deploys it to ECS using CloudFormation and ECS rolling update deployments. 7 | * [Sources: GitHub source, ECR base image] -> [CodeBuild build] -> [CloudFormation Deploy Actions to 'test' stack] -> [CloudFormation Deploy Actions to 'prod' stack] 8 | */ 9 | class TriviaGameBackendPipelineStack extends Stack { 10 | constructor(parent: App, name: string, props?: StackProps) { 11 | super(parent, name, props); 12 | 13 | new TriviaGameContainersCfnPipeline(this, 'Pipeline', { 14 | pipelineNameSuffix: 'trivia-backend-cfn-deploy', 15 | stackNamePrefix: 'TriviaBackend', 16 | templateNamePrefix: 'TriviaBackend', 17 | buildspecLocation: 'trivia-backend/infra/cdk/buildspec.yml', 18 | pipelineCdkFileName: 'api-service-pipeline', 19 | }); 20 | } 21 | } 22 | 23 | const app = new App(); 24 | new TriviaGameBackendPipelineStack(app, 'TriviaGameBackendPipeline', { 25 | env: { account: process.env['CDK_DEFAULT_ACCOUNT'], region: 'us-east-1' }, 26 | tags: { 27 | project: 'reinvent-trivia' 28 | } 29 | }); 30 | app.synth(); 31 | -------------------------------------------------------------------------------- /pipelines/src/canaries-pipeline.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { App, Stack, StackProps } from 'aws-cdk-lib'; 3 | import { TriviaGameCfnPipeline } from './common/cfn-pipeline'; 4 | 5 | class TriviaGameCanariesPipelineStack extends Stack { 6 | constructor(parent: App, name: string, props?: StackProps) { 7 | super(parent, name, props); 8 | 9 | new TriviaGameCfnPipeline(this, 'Pipeline', { 10 | pipelineName: 'canaries', 11 | stackName: 'Canaries', 12 | templateName: 'Canaries', 13 | directory: 'canaries', 14 | pipelineCdkFileName: 'canaries-pipeline', 15 | }); 16 | } 17 | } 18 | 19 | const app = new App(); 20 | new TriviaGameCanariesPipelineStack(app, 'TriviaGameCanariesPipeline', { 21 | env: { account: process.env['CDK_DEFAULT_ACCOUNT'], region: 'us-east-1' }, 22 | tags: { 23 | project: "reinvent-trivia" 24 | } 25 | }); 26 | app.synth(); -------------------------------------------------------------------------------- /pipelines/src/chat-bot-pipeline.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { App, Stack, StackProps } from 'aws-cdk-lib'; 3 | import { 4 | aws_codebuild as codebuild, 5 | aws_codepipeline_actions as actions, 6 | aws_iam as iam, 7 | } from 'aws-cdk-lib'; 8 | 9 | import { TriviaGameCfnPipeline } from './common/cfn-pipeline'; 10 | 11 | class TriviaGameChatBotPipelineStack extends Stack { 12 | constructor(parent: App, name: string, props?: StackProps) { 13 | super(parent, name, props); 14 | 15 | const pipelineConstruct = new TriviaGameCfnPipeline(this, 'Pipeline', { 16 | pipelineName: 'chat-bot', 17 | stackName: 'ChatBot', 18 | templateName: 'ChatBot', 19 | directory: 'chat-bot', 20 | pipelineCdkFileName: 'chat-bot-pipeline', 21 | }); 22 | const pipeline = pipelineConstruct.pipeline; 23 | 24 | // Use CodeBuild to run script that deploys the Lex model 25 | const lexProject = new codebuild.PipelineProject(this, 'LexProject', { 26 | buildSpec: codebuild.BuildSpec.fromSourceFilename('chat-bot/lex-model/buildspec.yml'), 27 | environment: { 28 | buildImage: codebuild.LinuxBuildImage.AMAZON_LINUX_2_5 29 | } 30 | }); 31 | 32 | lexProject.addToRolePolicy(new iam.PolicyStatement({ 33 | actions: [ 34 | 'lex:StartImport', 'lex:GetImport', 35 | 'lex:GetIntent', 'lex:PutIntent', 36 | 'lex:GetSlotType', 'lex:PutSlotType', 37 | 'lex:GetBot', 'lex:PutBot', 'lex:PutBotAlias' 38 | ], 39 | resources: ["*"] 40 | })); 41 | lexProject.addToRolePolicy(new iam.PolicyStatement({ 42 | actions: ['cloudformation:DescribeStackResource'], 43 | resources: [Stack.of(this).formatArn({ 44 | service: 'cloudformation', 45 | resource: 'stack', 46 | resourceName: 'TriviaGameChatBot*' 47 | })] 48 | })); 49 | 50 | const deployBotAction = new actions.CodeBuildAction({ 51 | actionName: 'Deploy', 52 | project: lexProject, 53 | input: pipelineConstruct.sourceOutput 54 | }); 55 | 56 | pipeline.addStage({ 57 | stageName: 'DeployLexBot', 58 | actions: [deployBotAction] 59 | }); 60 | } 61 | } 62 | 63 | const app = new App(); 64 | new TriviaGameChatBotPipelineStack(app, 'TriviaGameChatBotPipeline', { 65 | env: { account: process.env['CDK_DEFAULT_ACCOUNT'], region: 'us-east-1' }, 66 | tags: { 67 | project: "reinvent-trivia" 68 | } 69 | }); 70 | app.synth(); -------------------------------------------------------------------------------- /pipelines/src/pipelines-bootstrap.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { App, CfnOutput, Stack, StackProps } from 'aws-cdk-lib'; 3 | import { aws_codestarconnections as connections } from 'aws-cdk-lib'; 4 | 5 | class TriviaGamePipelinesBootstrap extends Stack { 6 | constructor(parent: App, name: string, props?: StackProps) { 7 | super(parent, name, props); 8 | 9 | // Create resources used by all the trivia game pipelines 10 | const codeStarConnection = new connections.CfnConnection(this, 'GitHubConnection', { 11 | connectionName: 'reinvent-trivia-repo', 12 | providerType: 'GitHub', 13 | }); 14 | 15 | new CfnOutput(this, 'CodeStarConnection', { 16 | value: codeStarConnection.attrConnectionArn, 17 | exportName: 'TriviaGamePipelinesCodeStarConnection' 18 | }); 19 | } 20 | } 21 | 22 | const app = new App(); 23 | new TriviaGamePipelinesBootstrap(app, 'TriviaGamePipelines', { 24 | env: { account: process.env['CDK_DEFAULT_ACCOUNT'], region: 'us-east-1' }, 25 | tags: { 26 | project: 'reinvent-trivia' 27 | } 28 | }); 29 | app.synth(); -------------------------------------------------------------------------------- /pipelines/src/static-site-pipeline.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { App, Fn, Stack, StackProps } from 'aws-cdk-lib'; 3 | import { 4 | aws_codebuild as codebuild, 5 | aws_codepipeline as codepipeline, 6 | aws_codestarnotifications as notifications, 7 | aws_codepipeline_actions as actions, 8 | aws_iam as iam, 9 | } from 'aws-cdk-lib'; 10 | 11 | class TriviaGameStaticSitePipeline extends Stack { 12 | constructor(parent: App, name: string, props?: StackProps) { 13 | super(parent, name, props); 14 | 15 | const pipeline = new codepipeline.Pipeline(this, "Pipeline", { 16 | pipelineName: "reinvent-trivia-game-static-site", 17 | restartExecutionOnUpdate: true, 18 | }); 19 | 20 | new notifications.CfnNotificationRule(this, 'PipelineNotifications', { 21 | name: pipeline.pipelineName, 22 | detailType: 'FULL', 23 | resource: pipeline.pipelineArn, 24 | eventTypeIds: [ 'codepipeline-pipeline-pipeline-execution-failed' ], 25 | targets: [ 26 | { 27 | targetType: 'SNS', 28 | targetAddress: Stack.of(this).formatArn({ 29 | service: 'sns', 30 | resource: 'reinvent-trivia-notifications' 31 | }), 32 | } 33 | ] 34 | }); 35 | 36 | // Source 37 | const githubConnection = Fn.importValue('TriviaGamePipelinesCodeStarConnection'); 38 | const sourceOutput = new codepipeline.Artifact('SourceArtifact'); 39 | const sourceAction = new actions.CodeStarConnectionsSourceAction({ 40 | actionName: 'GitHubSource', 41 | owner: 'aws-samples', 42 | repo: 'aws-reinvent-trivia-game', 43 | connectionArn: githubConnection, 44 | output: sourceOutput 45 | }); 46 | pipeline.addStage({ 47 | stageName: 'Source', 48 | actions: [sourceAction], 49 | }); 50 | 51 | // Update pipeline 52 | // This pipeline stage uses CodeBuild to self-mutate the pipeline by re-deploying the pipeline's CDK code 53 | // If the pipeline changes, it will automatically start again 54 | const pipelineProject = new codebuild.PipelineProject(this, "UpdatePipeline", { 55 | buildSpec: codebuild.BuildSpec.fromObjectToYaml({ 56 | version: '0.2', 57 | phases: { 58 | install: { 59 | 'runtime-versions': { 60 | nodejs: 'latest', 61 | }, 62 | commands: [ 63 | 'npm install -g aws-cdk', 64 | ], 65 | }, 66 | build: { 67 | commands: [ 68 | 'cd $CODEBUILD_SRC_DIR/pipelines', 69 | 'npm ci', 70 | 'npm run build', 71 | "cdk deploy --app 'node src/static-site-pipeline.js' --require-approval=never", 72 | ] 73 | }, 74 | }, 75 | }), 76 | environment: { 77 | buildImage: codebuild.LinuxBuildImage.AMAZON_LINUX_2_5, 78 | }, 79 | }); 80 | pipelineProject.addToRolePolicy( 81 | new iam.PolicyStatement({ 82 | actions: [ 83 | "cloudformation:*", 84 | "codebuild:*", 85 | "codepipeline:*", 86 | "s3:*", 87 | "kms:*", 88 | "codestar-notifications:*", 89 | "codestar-connections:*", 90 | "iam:*", 91 | "events:*", 92 | "ssm:*", 93 | ], 94 | resources: ["*"], 95 | }) 96 | ); 97 | const pipelineBuildAction = new actions.CodeBuildAction({ 98 | actionName: 'DeployPipeline', 99 | project: pipelineProject, 100 | input: sourceOutput, 101 | }); 102 | pipeline.addStage({ 103 | stageName: 'SyncPipeline', 104 | actions: [pipelineBuildAction], 105 | }); 106 | 107 | // Deploy to test site 108 | pipeline.addStage({ 109 | stageName: 'Test', 110 | actions: [this.createDeployAction('Test', sourceOutput)] 111 | }); 112 | 113 | // Deploy to prod site 114 | pipeline.addStage({ 115 | stageName: 'Prod', 116 | actions: [this.createDeployAction('Prod', sourceOutput)] 117 | }); 118 | } 119 | 120 | private createDeployAction(stageName: string, input: codepipeline.Artifact): actions.Action { 121 | const project = new codebuild.PipelineProject(this, stageName + 'Project', { 122 | buildSpec: codebuild.BuildSpec.fromSourceFilename('static-site/buildspec.yml'), 123 | environment: { 124 | buildImage: codebuild.LinuxBuildImage.AMAZON_LINUX_2_5, 125 | environmentVariables: { 126 | 'STAGE': { 127 | value: stageName.toLowerCase() 128 | } 129 | } 130 | } 131 | }); 132 | 133 | // Admin permissions needed for cdk deploy 134 | project.addToRolePolicy(new iam.PolicyStatement({ 135 | actions: ['*'], 136 | resources: ['*'] 137 | })); 138 | 139 | return new actions.CodeBuildAction({ 140 | actionName: 'Deploy' + stageName, 141 | project, 142 | input 143 | }); 144 | } 145 | } 146 | 147 | const app = new App(); 148 | new TriviaGameStaticSitePipeline(app, 'TriviaGameStaticSitePipeline', { 149 | env: { account: process.env['CDK_DEFAULT_ACCOUNT'], region: 'us-east-1' }, 150 | tags: { 151 | project: "reinvent-trivia" 152 | } 153 | }); 154 | app.synth(); -------------------------------------------------------------------------------- /pipelines/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target":"ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2016", "es2017.object", "es2017.string"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": false, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization":false, 20 | "resolveJsonModule": true, 21 | "esModuleInterop": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /static-site/README.md: -------------------------------------------------------------------------------- 1 | # Trivia static site 2 | 3 | The static site calls into the API backend service to retrieve questions and answers, then displays them in the browser. 4 | 5 | ## Customize 6 | 7 | Replace all references to 'reinvent-trivia.com' with your own domain name. This sample assumes that you already registered your domain name and created a [Route53 hosted zone](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/AboutHZWorkingWith.html) for the domain name in your AWS account. 8 | 9 | ## Static Pages 10 | 11 | The [app](app/) directory contains the static pages that need to be bundled and copied to the site's S3 bucket. 12 | 13 | ## Infrastructure 14 | 15 | The [cdk](cdk/) directory contains the infrastructure as code, including a CloudFront distribution and S3 bucket. It uses the [AWS Cloud Development Kit (AWS CDK)](https://github.com/awslabs/aws-cdk) to model infrastructure in Typescript, which then generates CloudFormation templates. 16 | 17 | ## Deploy 18 | 19 | See the commands in buildspec.yml for an example of how to bundle and deploy the site infrastructure and page content, or use the pipeline modeled in the "[pipelines](../pipelines/)" folder to deploy the pages for you. 20 | 21 | The CDK is used to both provision infrastructure like the S3 bucket and to deploy the site content to the bucket. To deploy (after bundling the pages in app/), compile using `npm run build` in the [cdk](cdk/) directory then use the `cdk deploy --app infrastructure.js TriviaGameStaticSiteInfraTest` command. 22 | 23 | ## Credits 24 | 25 | Static site based on [React Trivia](https://github.com/ccoenraets/react-trivia) 26 | -------------------------------------------------------------------------------- /static-site/app/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", "@babel/preset-react" 4 | ] 5 | } -------------------------------------------------------------------------------- /static-site/app/assets/css/styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Based on https://github.com/ccoenraets/react-trivia 3 | * Published under MIT license 4 | */ 5 | 6 | body { 7 | padding: 0; 8 | margin: 0; 9 | background: #232F3E; 10 | font-family: 'Amazon Ember', 'Helvetica', 'Arial', sans-serif; 11 | overflow: hidden; 12 | } 13 | 14 | p { 15 | margin: 0; 16 | } 17 | 18 | a { 19 | color: #FFFFFF; 20 | } 21 | 22 | .headers { 23 | position: absolute; 24 | color: #FFFFFF; 25 | font-size: 28px; 26 | /*padding-top: 16px;*/ 27 | display: -webkit-flex; 28 | display: flex; 29 | -webkit-align-items: center; 30 | align-items: center; 31 | height: 60px; 32 | } 33 | 34 | .footer { 35 | position: absolute; 36 | color: #FFFFFF; 37 | font-size: 16px; 38 | /*padding-top: 16px;*/ 39 | display: -webkit-flex; 40 | display: flex; 41 | -webkit-align-items: center; 42 | text-align: center; 43 | align-items: center; 44 | height: 60px; 45 | white-space: pre; 46 | } 47 | 48 | .headers>.header { 49 | display: inline-block; 50 | text-align: center; 51 | } 52 | 53 | .flipper { 54 | -webkit-perspective: 1200px; 55 | perspective: 1200px; 56 | position: absolute; 57 | -webkit-transform: translate3d(0, 0, 0); 58 | transform: translate3d(0, 0, 0); 59 | overflow: visible; 60 | } 61 | 62 | .flipping { 63 | transition: all 1s ease-in-out; 64 | z-index: 100; 65 | } 66 | 67 | 68 | .flipper.flipped { 69 | -webkit-transform: translate3d(0, 0, 0) !important; /* upper left corner overriding absolute positioning */ 70 | transform: translate3d(0, 0, 0) !important; 71 | width: 100% !important; /* full screen overriding absolute width */ 72 | height: 100% !important; /* full screen overriding absolute height */ 73 | z-index: 100; 74 | } 75 | 76 | .flipper.flipped .card { 77 | -webkit-transform: rotateY(-180deg); 78 | transform: rotateY(-180deg); 79 | } 80 | 81 | .card { 82 | -webkit-transform: translate3d(0, 0, 0); 83 | transform: translate3d(0, 0, 0); 84 | -webkit-transform-style: preserve-3d; 85 | transform-style: preserve-3d; 86 | transition: all 1s ease-in-out; 87 | position: relative; 88 | width: 100%; 89 | height: 100%; 90 | text-align: center; 91 | } 92 | 93 | .card > .front, 94 | .card > .back { 95 | -webkit-transform: translate3d(0, 0, 0); 96 | transform: translate3d(0, 0, 0); 97 | -webkit-backface-visibility: hidden; 98 | backface-visibility: hidden; 99 | position: absolute; 100 | top: 4px; 101 | left: 4px; 102 | bottom: 4px; 103 | right: 4px; 104 | display: -webkit-flex; 105 | display: flex; 106 | -webkit-flex-direction: column; 107 | flex-direction: column; 108 | -webkit-justify-content: center; 109 | justify-content: center; 110 | -webkit-align-items: center; 111 | align-items: center; 112 | overflow: hidden; 113 | background-size: 100% 100%; 114 | background-repeat: no-repeat; 115 | border: solid 4px #FFFFFF; 116 | border-radius: 4px; 117 | } 118 | 119 | .card > .front { 120 | -webkit-transform: rotateY(0deg) !important; 121 | transform: rotateY(0deg) !important; 122 | background-color: #00A1C9; 123 | color: #FFFFFF; 124 | font-size: 54px; 125 | z-index: 2; 126 | } 127 | 128 | .card > .back { 129 | -webkit-transform: rotateY(180deg) !important; 130 | transform: rotateY(180deg) !important; 131 | background-color: #007DBC; 132 | color: #FFFFFF; 133 | font-size: 36px; 134 | font-weight: 300; 135 | padding: 0 50px; 136 | } 137 | 138 | .flipper.done .front > .points { 139 | display: none; 140 | } 141 | 142 | 143 | .flipper.flipped .back { 144 | font-size: 60px; 145 | } 146 | 147 | .front>img { 148 | width: 80px; 149 | } 150 | 151 | .back img { 152 | max-width: 80%; 153 | max-height: 80%; 154 | } 155 | 156 | .back>img { 157 | position: absolute; 158 | bottom: 20px; 159 | right: 20px; 160 | width: 120px; 161 | } 162 | 163 | ul, ol { 164 | text-align: left; 165 | display: inline-block; 166 | } 167 | 168 | li { 169 | margin-top: 8px; 170 | } 171 | 172 | 173 | ol { 174 | list-style-type: upper-alpha; 175 | } 176 | 177 | code { 178 | margin-top: 28px; 179 | display: inline-block; 180 | font-size: 38px; 181 | overflow: auto; 182 | text-align: left; 183 | font-family: 'Source Code Pro', monospace; 184 | text-align: left; 185 | } 186 | 187 | @media screen and (min-width : 641px) and (max-width : 1024px) { 188 | 189 | .headers { 190 | font-size: 20px; 191 | } 192 | 193 | .footer { 194 | font-size: 15px; 195 | } 196 | 197 | .card > .front { 198 | font-size: 36px; 199 | } 200 | 201 | .card > .back { 202 | font-size: 15px; 203 | padding: 0 25px; 204 | } 205 | 206 | .flipper.flipped .back { 207 | font-size: 36px; 208 | } 209 | 210 | .card > .front img { 211 | width: 60px; 212 | } 213 | 214 | .card > .back > img { 215 | width: 48px; 216 | } 217 | 218 | code { 219 | margin-top: 24px; 220 | font-size: 24px; 221 | } 222 | 223 | .card > .front, 224 | .card > .back { 225 | top: 3px; 226 | left: 3px; 227 | bottom: 3px; 228 | right: 3px; 229 | } 230 | 231 | 232 | } 233 | 234 | @media screen and (min-width : 0) and (max-width : 640px) { 235 | 236 | .headers, .footer { 237 | font-size: 14px; 238 | height: 32px; 239 | } 240 | 241 | .card > .front { 242 | font-size: 24px; 243 | } 244 | 245 | .card > .back { 246 | font-size: 14px; 247 | padding: 0 14px; 248 | } 249 | 250 | .flipper.flipped .back { 251 | font-size: 20px; 252 | } 253 | 254 | .card > .front img { 255 | width: 30px; 256 | } 257 | 258 | .card > .back > img { 259 | width: 30px; 260 | } 261 | 262 | .card > .front, 263 | .card > .back { 264 | top: 2px; 265 | left: 2px; 266 | bottom: 2px; 267 | right: 2px; 268 | border: solid 2px #FFFFFF; 269 | } 270 | 271 | code { 272 | font-size: 14px; 273 | } 274 | 275 | } -------------------------------------------------------------------------------- /static-site/app/assets/img/aws.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /static-site/app/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | re:Invent Trivia: Page not found 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /static-site/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | re:Invent Trivia 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /static-site/app/js/Card.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Based on https://github.com/ccoenraets/react-trivia 3 | * Published under MIT license 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | class Card extends React.Component { 9 | 10 | constructor(props) { 11 | super(props); 12 | this.state = {view: 'points', completed: false}; 13 | } 14 | 15 | clickHandler(event) { 16 | if (this.state.view === 'points') { 17 | this.setState({view: 'question', flipping: true}); 18 | } else if (this.state.view === 'question') { 19 | this.setState({view: 'answer'}); 20 | } else { 21 | this.setState({view: 'points', completed: true, flipping: true}); 22 | } 23 | } 24 | 25 | getLabelBack() { 26 | return {__html: this.state.view === 'question' ? this.props.question.question : this.props.question.answer}; 27 | } 28 | 29 | transitionEndHandler(event) { 30 | if (event.propertyName === 'width') { 31 | this.setState({flipping: false}); 32 | } 33 | } 34 | 35 | render() { 36 | let style = { 37 | width: this.props.width + 'px', 38 | height: this.props.height + 'px', 39 | transform: 'translate3d(' + this.props.left + 'px,' + this.props.top + 'px,0)', 40 | WebkitTransform: 'translate3d(' + this.props.left + 'px,' + this.props.top + 'px,0)' 41 | }, 42 | front = this.state.completed ? : {this.props.question.points}, 43 | className = 'flipper'; 44 | 45 | if (this.state.view !== 'points') { 46 | className = className + ' flipped'; 47 | } 48 | if (this.state.flipping) { 49 | className = className + ' flipping'; 50 | } 51 | return ( 52 |
53 |
54 |
55 | {front} 56 |
57 |
58 | 59 | 60 |
61 |
62 |
63 | ); 64 | } 65 | 66 | }; 67 | 68 | export default Card; 69 | -------------------------------------------------------------------------------- /static-site/app/js/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class Footer extends React.Component { 4 | 5 | render() { 6 | 7 | let style = { 8 | width: this.props.width, 9 | height: this.props.height, 10 | transform: 'translate3d(0px,' + this.props.top + 'px,0)', 11 | WebkitTransform: 'translate3d(0px,' + this.props.top + 'px,0)' 12 | }; 13 | 14 | return ( 15 |
16 | 17 | © 2019, Amazon Web Services, Inc. or its affiliates. See the source code for this site on GitHub. 18 | 19 |
20 | ); 21 | } 22 | 23 | }; 24 | 25 | export default Footer; 26 | -------------------------------------------------------------------------------- /static-site/app/js/Headers.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Based on https://github.com/ccoenraets/react-trivia 3 | * Published under MIT license 4 | */ 5 | 6 | import React from 'react'; 7 | 8 | class Headers extends React.Component { 9 | 10 | render() { 11 | 12 | let style = { 13 | width: this.props.headerWidth 14 | }, 15 | headers = []; 16 | 17 | this.props.data.forEach((category, index) => headers.push({category.category})); 18 | 19 | return ( 20 |
21 | {headers} 22 |
23 | ); 24 | } 25 | 26 | }; 27 | 28 | export default Headers; 29 | -------------------------------------------------------------------------------- /static-site/app/js/app.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Based on https://github.com/ccoenraets/react-trivia 3 | * Published under MIT license 4 | */ 5 | import React from 'react'; 6 | import { createRoot } from "react-dom/client"; 7 | import Card from './Card'; 8 | import Headers from './Headers'; 9 | import Footer from './Footer'; 10 | import request from './request'; 11 | 12 | class App extends React.Component { 13 | 14 | constructor(props) { 15 | super(props); 16 | this.state = { 17 | windowWidth: window.innerWidth, 18 | windowHeight: window.innerHeight, 19 | data: [] 20 | }; 21 | } 22 | 23 | handleResize(event) { 24 | this.setState({ 25 | windowWidth: window.innerWidth, 26 | windowHeight: window.innerHeight 27 | }); 28 | } 29 | 30 | 31 | componentDidMount() { 32 | window.addEventListener('resize', this.handleResize.bind(this)); 33 | let rows = 0; 34 | data.forEach(category => { 35 | if (category.questions.length > rows) { 36 | rows = category.questions.length; 37 | } 38 | }); 39 | this.setState({data: data, rows: rows, cols: data.length}); 40 | } 41 | 42 | componentDidMount() { 43 | window.addEventListener('resize', this.handleResize.bind(this)); 44 | request({url: __TRIVIA_API__ + '/api/trivia/all'}).then(result => { 45 | let data = JSON.parse(result), 46 | rows = 0; 47 | data.forEach(category => { 48 | if (category.questions.length > rows) { 49 | rows = category.questions.length; 50 | } 51 | }); 52 | this.setState({data: data, rows: rows, cols: data.length}); 53 | }); 54 | } 55 | 56 | componentWillUnmount() { 57 | window.removeEventListener('resize', this.handleResize); 58 | } 59 | 60 | render() { 61 | let footerHeight = this.state.windowWidth > 640 ? 60 : 32, 62 | footerTop = this.state.windowHeight - footerHeight, 63 | headerHeight = this.state.windowWidth > 640 ? 60 : 32, 64 | cardWidth = this.state.windowWidth / this.state.cols, 65 | cardHeight = (this.state.windowHeight - headerHeight - footerHeight) / this.state.rows, 66 | cards = []; 67 | 68 | this.state.data.forEach((category, categoryIndex) => { 69 | let left = categoryIndex * cardWidth; 70 | category.questions.forEach((question, questionIndex) => { 71 | cards.push(); 72 | }) 73 | }); 74 | return ( 75 |
76 | 77 | {cards} 78 |
79 |
80 | ); 81 | } 82 | 83 | }; 84 | 85 | const root = createRoot(document.getElementById('app')); 86 | root.render(); 87 | -------------------------------------------------------------------------------- /static-site/app/js/pageNotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from "react-dom/client"; 3 | import Footer from './Footer'; 4 | 5 | class PageNotFound extends React.Component { 6 | 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | windowWidth: window.innerWidth, 11 | windowHeight: window.innerHeight, 12 | data: [] 13 | }; 14 | } 15 | 16 | handleResize(event) { 17 | this.setState({ 18 | windowWidth: window.innerWidth, 19 | windowHeight: window.innerHeight 20 | }); 21 | } 22 | 23 | componentDidMount() { 24 | window.addEventListener('resize', this.handleResize.bind(this)); 25 | } 26 | 27 | componentWillUnmount() { 28 | window.removeEventListener('resize', this.handleResize); 29 | } 30 | 31 | render() { 32 | let footerHeight = this.state.windowWidth > 640 ? 60 : 32, 33 | footerTop = this.state.windowHeight - footerHeight, 34 | headerStyle = { 35 | width: this.state.windowWidth, 36 | transform: 'translate3d(0px,20px,0)', 37 | WebkitTransform: 'translate3d(0px,20px,0)' 38 | }; 39 | 40 | return ( 41 |
42 |
43 | Page not found! 44 |
45 |
46 |
47 | ); 48 | } 49 | 50 | }; 51 | 52 | const root = createRoot(document.getElementById('pageNotFound')); 53 | root.render(); 54 | -------------------------------------------------------------------------------- /static-site/app/js/request.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Based on https://github.com/ccoenraets/react-trivia 3 | * Published under MIT license 4 | */ 5 | 6 | export default opts => { 7 | return new Promise((resolve, reject) => { 8 | let xhr = new XMLHttpRequest(); 9 | xhr.open(opts.method || "GET", opts.url); 10 | xhr.onload = () => { 11 | if (xhr.status >= 200 && xhr.status < 300) { 12 | resolve(xhr.response); 13 | } else { 14 | reject({ 15 | status: this.status, 16 | statusText: xhr.statusText 17 | }); 18 | } 19 | }; 20 | xhr.onerror = () => { 21 | reject({ 22 | status: this.status, 23 | statusText: xhr.statusText 24 | }); 25 | }; 26 | if (opts.headers) { 27 | Object.keys(opts.headers).forEach(key => { 28 | xhr.setRequestHeader(key, opts.headers[key]); 29 | }); 30 | } 31 | 32 | xhr.send(opts.data); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /static-site/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-trivia-game-static-site", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack --config ./webpack.config.js --progress --color && webpack --config ./webpack-errorpage.config.js --progress --color", 8 | "build:dev": "NODE_ENV=development npm run build", 9 | "build:test": "NODE_ENV=test npm run build", 10 | "build:prod": "NODE_ENV=production npm run build" 11 | }, 12 | "devDependencies": { 13 | "@babel/core": "^7.27.4", 14 | "@babel/preset-env": "^7.27.2", 15 | "@babel/preset-react": "^7.27.1", 16 | "babel-loader": "^10.0.0", 17 | "react": "^19.1.0", 18 | "react-dom": "^19.1.0", 19 | "webpack": "^5.99.9", 20 | "webpack-cli": "^6.0.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /static-site/app/webpack-errorpage.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | entry: './js/pageNotFound.js', 6 | output: { 7 | path: path.resolve(__dirname, 'build'), 8 | filename: 'pageNotFound.bundle.js' 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.js$/, 14 | exclude: /node_modules/, 15 | use: "babel-loader" 16 | }, 17 | { 18 | test: /\.jsx?$/, 19 | exclude: /node_modules/, 20 | use: "babel-loader" 21 | } 22 | ] 23 | }, 24 | stats: { 25 | colors: true 26 | }, 27 | devtool: 'source-map' 28 | }; 29 | -------------------------------------------------------------------------------- /static-site/app/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Based on https://github.com/ccoenraets/react-trivia 3 | * Published under MIT license 4 | */ 5 | 6 | var path = require('path'); 7 | var webpack = require('webpack'); 8 | 9 | var triviaEndpoint; 10 | var setupEndpoint = function() { 11 | switch(process.env.NODE_ENV) { 12 | case 'production': 13 | triviaEndpoint = 'https://api.reinvent-trivia.com'; 14 | break; 15 | case 'test': 16 | triviaEndpoint = 'https://api-test.reinvent-trivia.com'; 17 | break; 18 | case 'development': 19 | case 'local': 20 | default: 21 | triviaEndpoint = 'http://localhost'; 22 | break; 23 | } 24 | }; 25 | setupEndpoint(); 26 | 27 | module.exports = { 28 | entry: './js/app.js', 29 | output: { 30 | path: path.resolve(__dirname, 'build'), 31 | filename: 'app.bundle.js' 32 | }, 33 | plugins: [ 34 | new webpack.DefinePlugin({ 35 | '__TRIVIA_API__': JSON.stringify(triviaEndpoint) 36 | }) 37 | ], 38 | module: { 39 | rules: [ 40 | { 41 | test: /\.js$/, 42 | exclude: /node_modules/, 43 | use: "babel-loader" 44 | }, 45 | { 46 | test: /\.jsx?$/, 47 | exclude: /node_modules/, 48 | use: "babel-loader" 49 | } 50 | ] 51 | }, 52 | stats: { 53 | colors: true 54 | }, 55 | devtool: 'source-map' 56 | }; 57 | -------------------------------------------------------------------------------- /static-site/buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | env: 4 | variables: 5 | STAGE: "dev" 6 | 7 | phases: 8 | install: 9 | runtime-versions: 10 | nodejs: latest 11 | commands: 12 | # Install dependencies for both site content and for CDK 13 | - npm install -g aws-cdk 14 | - cd static-site/app 15 | - npm ci 16 | - cd ../cdk 17 | - npm ci 18 | build: 19 | commands: 20 | # Compile the site content 21 | - cd ../app/ 22 | - npm run build:$STAGE 23 | - cp index.html error.html build/ 24 | - cp -rf assets build/ 25 | # Deploy via the CDK 26 | - cd ../cdk 27 | - npm run build 28 | - npm run bootstrap-infra 29 | - npm run deploy-infra-$STAGE 30 | -------------------------------------------------------------------------------- /static-site/cdk/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.d.ts 3 | node_modules 4 | cdk.context.json 5 | cdk.out 6 | -------------------------------------------------------------------------------- /static-site/cdk/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | -------------------------------------------------------------------------------- /static-site/cdk/infrastructure.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { App, Stack, StackProps } from 'aws-cdk-lib'; 3 | import { StaticSite } from './static-site'; 4 | import { RootDomainSite } from './root-domain-site'; 5 | 6 | interface TriviaGameInfrastructureStackProps extends StackProps { 7 | domainName: string; 8 | siteSubDomain: string; 9 | } 10 | 11 | class TriviaGameInfrastructureStack extends Stack { 12 | constructor(parent: App, name: string, props: TriviaGameInfrastructureStackProps) { 13 | super(parent, name, props); 14 | 15 | new StaticSite(this, 'StaticSite', { 16 | domainName: props.domainName, 17 | siteSubDomain: props.siteSubDomain 18 | }); 19 | } 20 | } 21 | 22 | interface TriviaGameRootDomainStackProps extends StackProps { 23 | domainName: string; 24 | } 25 | 26 | class TriviaGameRootDomainStack extends Stack { 27 | constructor(parent: App, name: string, props: TriviaGameRootDomainStackProps) { 28 | super(parent, name, props); 29 | 30 | new RootDomainSite(this, 'StaticSite', { 31 | domainName: props.domainName, 32 | originSubDomain: 'www' 33 | }); 34 | } 35 | } 36 | 37 | const app = new App(); 38 | new TriviaGameInfrastructureStack(app, 'TriviaGameStaticSiteInfraTest', { 39 | domainName: 'reinvent-trivia.com', 40 | siteSubDomain: 'test', 41 | env: { account: process.env['CDK_DEFAULT_ACCOUNT'], region: 'us-east-1' }, 42 | tags: { 43 | project: "reinvent-trivia" 44 | } 45 | }); 46 | new TriviaGameInfrastructureStack(app, 'TriviaGameStaticSiteInfraProd', { 47 | domainName: 'reinvent-trivia.com', 48 | siteSubDomain: 'www', 49 | env: { account: process.env['CDK_DEFAULT_ACCOUNT'], region: 'us-east-1' }, 50 | tags: { 51 | project: "reinvent-trivia" 52 | } 53 | }); 54 | new TriviaGameRootDomainStack(app, 'TriviaGameRootDomainRedirectProd', { 55 | domainName: 'reinvent-trivia.com', 56 | env: { account: process.env['CDK_DEFAULT_ACCOUNT'], region: 'us-east-1' }, 57 | tags: { 58 | project: "reinvent-trivia" 59 | } 60 | }); 61 | app.synth(); 62 | -------------------------------------------------------------------------------- /static-site/cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trivia-game-static-site-infrastructure", 3 | "version": "0.1.0", 4 | "main": "bin/index.js", 5 | "types": "bin/index.d.ts", 6 | "scripts": { 7 | "build": "tsc", 8 | "watch": "tsc -w", 9 | "synth-infra": "cdk synth -o build --app 'node infrastructure.js'", 10 | "bootstrap-infra": "cdk bootstrap --app 'node infrastructure.js'", 11 | "deploy-infra-test": "cdk deploy --app 'node infrastructure.js' --require-approval never TriviaGameStaticSiteInfraTest", 12 | "deploy-infra-prod": "cdk deploy --app 'node infrastructure.js' --require-approval never TriviaGameStaticSiteInfraProd", 13 | "deploy-infra-root-domain": "cdk deploy --app 'node infrastructure.js' --require-approval never TriviaGameRootDomainRedirectProd" 14 | }, 15 | "devDependencies": { 16 | "@types/node": "^22.15.29", 17 | "aws-cdk": "^2.1017.1", 18 | "typescript": "^5.8.3" 19 | }, 20 | "dependencies": { 21 | "aws-cdk-lib": "^2.199.0", 22 | "constructs": "^10.4.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /static-site/cdk/root-domain-site.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Construct } from 'constructs'; 4 | import { CfnOutput } from 'aws-cdk-lib'; 5 | import { 6 | aws_certificatemanager as acm, 7 | aws_cloudfront as cloudfront, 8 | aws_cloudfront_origins as origins, 9 | aws_route53 as route53, 10 | aws_route53_targets as targets, 11 | } from 'aws-cdk-lib'; 12 | 13 | export interface RootDomainSiteProps { 14 | domainName: string; 15 | originSubDomain: string; 16 | } 17 | 18 | export class RootDomainSite extends Construct { 19 | constructor(parent: Construct, name: string, props: RootDomainSiteProps) { 20 | super(parent, name); 21 | 22 | const originDomain = props.originSubDomain + '.' + props.domainName; 23 | const zone = route53.HostedZone.fromLookup(this, 'Zone', { domainName: props.domainName }); 24 | 25 | // TLS certificate 26 | const certificate = new acm.Certificate(this, 'SiteCertificate', { 27 | domainName: props.domainName, 28 | validation: acm.CertificateValidation.fromDns(zone), 29 | }); 30 | new CfnOutput(this, 'Certificate', { value: certificate.certificateArn }); 31 | 32 | // CloudFront distribution that provides HTTPS 33 | const distribution = new cloudfront.Distribution(this, 'SiteDistribution', { 34 | defaultBehavior: { 35 | origin: new origins.HttpOrigin(originDomain), 36 | viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, 37 | }, 38 | domainNames: [ props.domainName ], 39 | certificate, 40 | }); 41 | 42 | // Override the distribution logical ID since this was previously a CloudFrontWebDistribution object 43 | // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudfront-readme.html#migrating-from-the-original-cloudfrontwebdistribution-to-the-newer-distribution-construct 44 | const cfnDistribution = distribution.node.defaultChild as cloudfront.CfnDistribution; 45 | cfnDistribution.overrideLogicalId('StaticSiteSiteDistributionCFDistribution500D676B'); 46 | 47 | new CfnOutput(this, 'DistributionId', { value: distribution.distributionId }); 48 | 49 | // Route53 alias record for the CloudFront distribution 50 | new route53.ARecord(this, 'SiteAliasRecord', { 51 | recordName: props.domainName, 52 | target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)), 53 | zone 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /static-site/cdk/static-site.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Construct } from 'constructs'; 3 | import { CfnOutput } from 'aws-cdk-lib'; 4 | import { 5 | aws_certificatemanager as acm, 6 | aws_cloudfront as cloudfront, 7 | aws_cloudfront_origins as origins, 8 | aws_route53 as route53, 9 | aws_route53_targets as targets, 10 | aws_s3 as s3, 11 | aws_s3_deployment as s3deploy, 12 | } from 'aws-cdk-lib'; 13 | 14 | export interface StaticSiteProps { 15 | domainName: string; 16 | siteSubDomain: string; 17 | } 18 | 19 | export class StaticSite extends Construct { 20 | constructor(parent: Construct, name: string, props: StaticSiteProps) { 21 | super(parent, name); 22 | 23 | const siteDomain = props.siteSubDomain + '.' + props.domainName; 24 | new CfnOutput(this, 'Site', { value: 'https://' + siteDomain }); 25 | const zone = route53.HostedZone.fromLookup(this, 'Zone', { domainName: props.domainName }); 26 | 27 | // Content bucket 28 | const siteBucket = new s3.Bucket(this, 'SiteBucket', { 29 | bucketName: siteDomain 30 | }); 31 | new CfnOutput(this, 'Bucket', { value: siteBucket.bucketName }); 32 | 33 | // TLS certificate 34 | const certificate = new acm.Certificate(this, 'SiteCertificate', { 35 | domainName: siteDomain, 36 | validation: acm.CertificateValidation.fromDns(zone), 37 | }); 38 | new CfnOutput(this, 'Certificate', { value: certificate.certificateArn }); 39 | 40 | // CloudFront distribution that provides HTTPS 41 | const distribution = new cloudfront.Distribution(this, 'SiteDistribution', { 42 | defaultBehavior: { 43 | origin: new origins.S3Origin(siteBucket), 44 | viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, 45 | }, 46 | defaultRootObject: 'index.html', 47 | domainNames: [ siteDomain ], 48 | certificate, 49 | errorResponses: [ 50 | { 51 | httpStatus: 404, 52 | responseHttpStatus: 404, 53 | responsePagePath: '/error.html' 54 | }, 55 | { 56 | httpStatus: 403, 57 | responseHttpStatus: 404, 58 | responsePagePath: '/error.html' 59 | } 60 | ] 61 | }); 62 | 63 | // Override the distribution logical ID since this was previously a CloudFrontWebDistribution object 64 | // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cloudfront-readme.html#migrating-from-the-original-cloudfrontwebdistribution-to-the-newer-distribution-construct 65 | const cfnDistribution = distribution.node.defaultChild as cloudfront.CfnDistribution; 66 | cfnDistribution.overrideLogicalId('StaticSiteSiteDistributionCFDistribution500D676B'); 67 | 68 | new CfnOutput(this, 'DistributionId', { value: distribution.distributionId }); 69 | 70 | // Route53 alias record for the CloudFront distribution 71 | new route53.ARecord(this, 'SiteAliasRecord', { 72 | recordName: siteDomain, 73 | target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)), 74 | zone 75 | }); 76 | 77 | // Deploy site contents to S3 bucket 78 | new s3deploy.BucketDeployment(this, 'DeployWithInvalidation', { 79 | sources: [ s3deploy.Source.asset('../app/build') ], 80 | destinationBucket: siteBucket, 81 | distribution, 82 | distributionPaths: ['/*'], 83 | }); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /static-site/cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target":"ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2016", "es2017.object", "es2017.string"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": false, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization":false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /trivia-backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .dockerignore 3 | *Dockerfile* 4 | npm-debug.log 5 | .git -------------------------------------------------------------------------------- /trivia-backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM reinvent-trivia-backend-base:release 2 | 3 | ARG NODE_ENV=production 4 | ARG PORT=80 5 | 6 | ENV NODE_ENV $NODE_ENV 7 | ENV PORT=$PORT 8 | 9 | WORKDIR /opt/app 10 | COPY app/package.json app/package-lock.json ./ 11 | RUN npm ci && npm prune --production && npm cache clean --force 12 | COPY ./app /opt/app 13 | COPY ./data /opt/data 14 | RUN apidoc -i routes/ -o apidoc/ 15 | 16 | HEALTHCHECK --interval=30s CMD node healthcheck.js 17 | 18 | EXPOSE $PORT 19 | 20 | CMD [ "node", "service.js" ] -------------------------------------------------------------------------------- /trivia-backend/app/apidoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-reinvent-trivia-api", 3 | "version": "0.1.0", 4 | "description": "re:Invent Trivia API documentation", 5 | "title": "re:Invent Trivia API documentation", 6 | "sampleUrl": "/" 7 | } 8 | -------------------------------------------------------------------------------- /trivia-backend/app/healthcheck.js: -------------------------------------------------------------------------------- 1 | var http = require("http"); 2 | 3 | var options = { 4 | timeout: 2000, 5 | host: 'localhost', 6 | port: process.env.PORT || 8080, 7 | path: '/health' 8 | }; 9 | 10 | var request = http.request(options, (res) => { 11 | console.info('STATUS: ' + res.statusCode); 12 | process.exitCode = (res.statusCode === 200) ? 0 : 1; 13 | process.exit(); 14 | }); 15 | 16 | request.on('error', function(err) { 17 | console.error('ERROR', err); 18 | process.exit(1); 19 | }); 20 | 21 | request.end(); 22 | -------------------------------------------------------------------------------- /trivia-backend/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-trivia-game-backend", 3 | "version": "1.0.0", 4 | "main": "service.js", 5 | "scripts": { 6 | "start": "node service.js", 7 | "test": "./node_modules/nyc/bin/nyc.js --reporter=lcov ./node_modules/mocha/bin/mocha" 8 | }, 9 | "dependencies": { 10 | "@godaddy/terminus": "^4.12.1", 11 | "body-parser": "^2.2.0", 12 | "compression": "^1.8.0", 13 | "cors": "^2.8.5", 14 | "express": "^5.1.0", 15 | "helmet": "^8.1.0", 16 | "morgan": "^1.10.0" 17 | }, 18 | "devDependencies": { 19 | "mocha": "^11.5.0", 20 | "chai": "^5.2.0", 21 | "chai-http": "^5.1.2", 22 | "nyc": "^17.1.0" 23 | }, 24 | "private": "true" 25 | } 26 | -------------------------------------------------------------------------------- /trivia-backend/app/routes/load.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | 3 | const router = express.Router(); 4 | 5 | /** 6 | * @api {get} api/load Generate CPU-intensive load 7 | * @apiName GenerateLoad 8 | * @apiGroup LoadAPI 9 | * 10 | * @apiSuccess {Number} result Number computed. 11 | * 12 | * @apiSuccessExample Success-Response: 13 | * HTTP/1.1 200 OK 14 | * { 15 | * "result": 1234 16 | * } 17 | * 18 | * @apiExample {curl} Example usage: 19 | * curl -i https://api.reinvent-trivia.com/api/load 20 | */ 21 | router.get('/', function(req, res, next) { 22 | var now = new Date().getTime(); 23 | var result = 0; 24 | // block for 1 second with pointless computations 25 | while(true) { 26 | result += Math.random() * Math.random(); 27 | if (new Date().getTime() > now + 1000) { 28 | break; 29 | } 30 | } 31 | 32 | res.send({ result: result }); 33 | }); 34 | 35 | module.exports = router; 36 | -------------------------------------------------------------------------------- /trivia-backend/app/routes/trivia.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const questions = require('../../data/questions.json'); 3 | 4 | const router = express.Router(); 5 | 6 | /** 7 | * @api {get} api/trivia/all Get all questions 8 | * @apiName GetAllQuestions 9 | * @apiGroup TriviaAPI 10 | * 11 | * @apiSuccess {Object[]} categories Array of categories. 12 | * @apiSuccess {String} categories.category Category name. 13 | * @apiSuccess {Object[]} categories.questions This category's questions. 14 | * @apiSuccess {Number} categories.questions.id Unique id of the question. 15 | * @apiSuccess {Number} categories.questions.points How many points the question is worth. 16 | * @apiSuccess {String} categories.questions.question Question text. 17 | * @apiSuccess {Object} categories.questions.answer Question answer text. 18 | * @apiSuccess {String} categories.questions.answerType Type of the answer (NUMBER or STRING). 19 | * 20 | * @apiSuccessExample Success-Response: 21 | * HTTP/1.1 200 OK 22 | * [ 23 | * { 24 | * "category": "Know Your History", 25 | * "questions": [ 26 | * { 27 | * "id": 1, 28 | * "points": 100, 29 | * "question": "What year was the first AWS re:Invent held?", 30 | * "answer": 2012, 31 | * "answerType": "NUMBER" 32 | * } 33 | * ] 34 | * } 35 | * ] 36 | * 37 | * @apiExample {curl} Example usage: 38 | * curl -i https://api.reinvent-trivia.com/api/trivia/all 39 | */ 40 | router.get('/all', function(req, res, next) { 41 | res.send(questions); 42 | }); 43 | 44 | /** 45 | * @api {get} api/trivia/question/:id Request question 46 | * @apiName GetQuestion 47 | * @apiGroup TriviaAPI 48 | * 49 | * @apiParam {Number} id Question unique ID. 50 | * 51 | * @apiSuccess {Number} id Unique id of the question. 52 | * @apiSuccess {Number} points How many points the question is worth. 53 | * @apiSuccess {String} question Question text. 54 | * @apiSuccess {Object} answer Question answer text. 55 | * @apiSuccess {String} answerType Type of the answer (NUMBER or STRING). 56 | * @apiSuccess {String} category The question's category. 57 | * 58 | * @apiSuccessExample Success-Response: 59 | * HTTP/1.1 200 OK 60 | * { 61 | * "id": 1, 62 | * "points": 100, 63 | * "question": "What year was the first AWS re:Invent held?", 64 | * "answer": 2012, 65 | * "answerType": "NUMBER" 66 | * } 67 | * 68 | * @apiError QuestionNotFound 404 The id of the question was not found. 69 | * 70 | * @apiErrorExample QuestionNotFound: 71 | * HTTP/1.1 404 Not Found 72 | * { 73 | * 'error': 'Not Found' 74 | * } 75 | * 76 | * @apiExample {curl} Example usage: 77 | * curl -i https://api.reinvent-trivia.com/api/trivia/question/1 78 | */ 79 | router.get('/question/:question_id', function(req, res, next) { 80 | var id = req.params.question_id; 81 | 82 | var foundQuestion; 83 | questions.forEach(function(category) { 84 | category.questions.forEach(function(question) { 85 | if (question.id == id) { 86 | foundQuestion = question; 87 | foundQuestion.category = category.category; 88 | } 89 | }); 90 | }); 91 | 92 | if (foundQuestion) { 93 | res.json(foundQuestion); 94 | } else { 95 | var err = new Error('Not Found'); 96 | err.status = 404; 97 | next(err); 98 | } 99 | }); 100 | 101 | /** 102 | * @api {post} api/trivia/question/:id Answer question 103 | * @apiName AnswerQuestion 104 | * @apiGroup TriviaAPI 105 | * 106 | * @apiParam {Number} id Question unique ID. 107 | * @apiParam {String} answer Question answer. 108 | * @apiParamExample {json} Request Body Example: 109 | * { 110 | * 'answer': 'Broomball' 111 | * } 112 | * 113 | * @apiSuccess {Number} id Unique id of the question. 114 | * @apiSuccess {Boolean} result Whether the given answer was correct (true/false). 115 | * 116 | * @apiSuccessExample Success-Response: 117 | * HTTP/1.1 200 OK 118 | * { 119 | * 'id': 1, 120 | * 'result': true 121 | * } 122 | * 123 | * @apiError QuestionNotFound The id of the question was not found. 124 | * 125 | * @apiErrorExample Error-Response: 126 | * HTTP/1.1 404 Not Found 127 | * { 128 | * 'error': 'Not Found' 129 | * } 130 | * @apiExample {curl} Example usage: 131 | * curl -H "Content-Type: application/json" \ 132 | * -d "{'answer' : 'Broomball'}" \ 133 | * -X POST https://api.reinvent-trivia.com/api/trivia/question/1 134 | */ 135 | router.post('/question/:question_id', function(req, res, next) { 136 | var id = req.params.question_id; 137 | var answer = req.body.answer; 138 | 139 | var foundQuestion; 140 | questions.forEach(function(category) { 141 | category.questions.forEach(function(question) { 142 | if (question.id == id) { 143 | foundQuestion = question; 144 | } 145 | }); 146 | }); 147 | 148 | if (foundQuestion) { 149 | if (foundQuestion.answerType == 'NUMBER' && typeof answer === 'string') { 150 | answer = parseInt(answer, 10); 151 | } 152 | 153 | var isCorrect = (foundQuestion.answer == answer); 154 | res.json({ "result" : isCorrect, "id": id }); 155 | } else { 156 | var err = new Error('Not Found'); 157 | err.status = 404; 158 | next(err); 159 | } 160 | }); 161 | 162 | module.exports = router; 163 | -------------------------------------------------------------------------------- /trivia-backend/app/service.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const express = require('express'); 3 | const path = require('path'); 4 | const morgan = require('morgan'); 5 | const cors = require('cors'); 6 | const helmet = require('helmet'); 7 | const compression = require('compression'); 8 | const bodyParser = require('body-parser'); 9 | const { createTerminus } = require('@godaddy/terminus'); 10 | const trivia = require('./routes/trivia'); 11 | const load = require('./routes/load'); 12 | 13 | const app = express(); 14 | 15 | // Configuration 16 | const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 8080; 17 | const env = process.env.NODE_ENV || "production"; 18 | 19 | app.use(bodyParser.json()); 20 | app.use(bodyParser.urlencoded({ extended: true })); 21 | 22 | app.use(cors()); 23 | 24 | app.use(helmet()); 25 | 26 | app.use(compression()); 27 | 28 | app.use(morgan(':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] :response-time ms')); 29 | 30 | // APIs 31 | app.use('/api/trivia', trivia); 32 | app.use('/api/load', load); 33 | app.use('/api/docs', express.static('apidoc')); 34 | 35 | // Error handling 36 | app.use(function(req, res, next) { 37 | var err = new Error('Not Found'); 38 | err.status = 404; 39 | next(err); 40 | }); 41 | 42 | app.use(function(err, req, res, next) { 43 | res.status(err.status || 500); 44 | res.json({ 45 | message: err.message, 46 | error: env === 'development' ? err : {} 47 | }); 48 | }); 49 | 50 | // Health checks and graceful shutdown 51 | const server = http.createServer(app); 52 | 53 | function onSignal() { 54 | console.log('server is starting cleanup'); 55 | return Promise.all([ 56 | // add any clean-up logic 57 | ]); 58 | } 59 | 60 | function onShutdown () { 61 | console.log('cleanup finished, server is shutting down'); 62 | } 63 | 64 | function onHealthCheck() { 65 | return Promise.resolve(); 66 | } 67 | 68 | createTerminus(server, { 69 | signals: ['SIGHUP','SIGINT','SIGTERM'], 70 | healthChecks: { 71 | '/health': onHealthCheck, 72 | '/': onHealthCheck 73 | }, 74 | onSignal, 75 | onShutdown 76 | }); 77 | 78 | server.listen(port, () => { 79 | console.log(`Listening on port ${port}`); 80 | }); 81 | 82 | module.exports = server; 83 | -------------------------------------------------------------------------------- /trivia-backend/artillery-load-api.yml: -------------------------------------------------------------------------------- 1 | # Run with: 2 | # artillery run -t https://api-test.reinvent-trivia.com artillery-load-api.yml 3 | 4 | config: 5 | http: 6 | pool: 250 7 | phases: 8 | - duration: 600 9 | arrivalRate: 10 10 | name: "Constant load" 11 | scenarios: 12 | - name: "Generate CPU-intensive load for one second" 13 | weight: 1 14 | flow: 15 | - get: 16 | url: "/api/load" 17 | -------------------------------------------------------------------------------- /trivia-backend/artillery-trivia-api.yml: -------------------------------------------------------------------------------- 1 | # Run with: 2 | # artillery run -t https://api-test.reinvent-trivia.com artillery-trivia-api.yml 3 | 4 | config: 5 | http: 6 | pool: 250 7 | phases: 8 | - duration: 600 9 | arrivalRate: 10 10 | name: "Minimal load" 11 | - duration: 1200 12 | arrivalRate: 10 13 | rampTo: 1000 14 | name: "Ramp up load" 15 | - duration: 1800 16 | # This seems to be the max RPS for single core. 17 | # Run multiple instances of artillery to utilize multiple cores. 18 | arrivalRate: 1000 19 | name: "High load" 20 | scenarios: 21 | - name: "Documentation" 22 | weight: 1 23 | flow: 24 | - get: 25 | url: "/api/docs/" 26 | - name: "Get all questions" 27 | weight: 5 28 | flow: 29 | - get: 30 | url: "/api/trivia/all" 31 | - name: "Get single question" 32 | weight: 2 33 | flow: 34 | - get: 35 | url: "/api/trivia/question/1" 36 | - name: "Answer single question" 37 | weight: 2 38 | flow: 39 | - post: 40 | url: "/api/trivia/question/1" 41 | json: 42 | answer: "Broomball" 43 | -------------------------------------------------------------------------------- /trivia-backend/base/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/node:24 2 | 3 | RUN npm install -g apidoc 4 | -------------------------------------------------------------------------------- /trivia-backend/base/buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | env: 4 | variables: 5 | IMAGE_REPO_NAME: reinvent-trivia-backend-base 6 | IMAGE_TAG: release 7 | 8 | phases: 9 | pre_build: 10 | commands: 11 | # Set up environment variables 12 | - cd $CODEBUILD_SRC_DIR/trivia-backend/base 13 | - AWS_ACCOUNT_ID=`echo $CODEBUILD_BUILD_ARN | awk -F":" '{print $5}'` 14 | - ECR_REPO=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME 15 | - aws ecr get-login-password | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com 16 | build: 17 | commands: 18 | # Build Docker image 19 | - docker build -t $IMAGE_REPO_NAME:$IMAGE_TAG . 20 | - docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $ECR_REPO:$IMAGE_TAG 21 | - docker push $ECR_REPO:$IMAGE_TAG 22 | -------------------------------------------------------------------------------- /trivia-backend/data/questions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "category": "Know Your History", 4 | "questions": [ 5 | { 6 | "id": 1, 7 | "points": 100, 8 | "question": "What year was the first AWS re:Invent held?", 9 | "answer": 2012, 10 | "answerType": "NUMBER" 11 | }, 12 | { 13 | "id": 2, 14 | "points": 200, 15 | "question": "What Amazon-favorite sport was introduced to re:Invent attendees in 2016?", 16 | "answer": "Broomball", 17 | "answerType": "STRING", 18 | "alternativeAnswers": [ 19 | ] 20 | }, 21 | { 22 | "id": 3, 23 | "points": 300, 24 | "question": "What world record was set at the 2017 re:Invent Tatonka Challenge?", 25 | "answer": "Largest chicken wing eating contest: 214 people", 26 | "answerType": "STRING", 27 | "alternativeAnswers": [ 28 | "wing eating contest", 29 | "largest wing eating contest", 30 | "biggest wing eating contest", 31 | "largest chicken wing eating contest", 32 | "biggest chicken wing eating contest", 33 | "wing eating competition", 34 | "largest wing eating competition", 35 | "biggest wing eating competition", 36 | "largest chicken wing eating competition", 37 | "biggest chicken wing eating competition" 38 | ] 39 | }, 40 | { 41 | "id": 4, 42 | "points": 400, 43 | "question": "What year were both Lambda and ECS (Elastic Container Service) announced at re:Invent?", 44 | "answer": 2014, 45 | "answerType": "NUMBER" 46 | } 47 | ] 48 | }, 49 | { 50 | "category": "The Main Event", 51 | "questions": [ 52 | { 53 | "id": 5, 54 | "points": 100, 55 | "question": "What webinar helps people learn about re:Invent leading up to the big event?", 56 | "answer": "How to re:Invent", 57 | "answerType": "STRING", 58 | "alternativeAnswers": [ 59 | "how to reinvent" 60 | ] 61 | }, 62 | { 63 | "id": 6, 64 | "points": 200, 65 | "question": "How many venues does the re:Invent campus span this year (2019)?", 66 | "answer": 6, 67 | "answerType": "NUMBER" 68 | }, 69 | { 70 | "id": 7, 71 | "points": 300, 72 | "question": "What year did twitch.tv/aws start streaming at re:Invent?", 73 | "answer": 2016, 74 | "answerType": "NUMBER" 75 | }, 76 | { 77 | "id": 8, 78 | "points": 400, 79 | "question": "How many chalk talks are scheduled for re:Invent 2019 (as of Nov 17)?", 80 | "answer": 694, 81 | "answerType": "NUMBER" 82 | } 83 | ] 84 | }, 85 | { 86 | "category": "Let's Get Musical", 87 | "questions": [ 88 | { 89 | "id": 9, 90 | "points": 100, 91 | "question": "What genre of music is usually played at the re:Play party?", 92 | "answer": "EDM", 93 | "answerType": "STRING", 94 | "alternativeAnswers": [ 95 | "electronica", 96 | "DJ", 97 | "electronic dance music", 98 | "electronic music", 99 | "house music", 100 | "house" 101 | ] 102 | }, 103 | { 104 | "id": 10, 105 | "points": 200, 106 | "question": "What kind of musical group kicks off Midnight Madness?", 107 | "answer": "Marching Band", 108 | "answerType": "STRING", 109 | "alternativeAnswers": [ 110 | "marching bands" 111 | ] 112 | }, 113 | { 114 | "id": 11, 115 | "points": 300, 116 | "question": "What venue hosted the re:Play party in 2017?", 117 | "answer": "The Linq", 118 | "answerType": "STRING", 119 | "alternativeAnswers": [ 120 | "the link", 121 | "linq", 122 | "link" 123 | ] 124 | }, 125 | { 126 | "id": 12, 127 | "points": 400, 128 | "question": "Who was the re:Play DJ at the 2013 re:Invent?", 129 | "answer": "DeadMau5", 130 | "answerType": "STRING", 131 | "alternativeAnswers": [ 132 | "deadmau5", 133 | "Joel Thomas Zimmerman", 134 | "Joel Zimmerman", 135 | "dead mouse", 136 | "deadmouse", 137 | "deadmous" 138 | ] 139 | } 140 | ] 141 | }, 142 | { 143 | "category": "People, Places, and Things", 144 | "questions": [ 145 | { 146 | "id": 13, 147 | "points": 100, 148 | "question": "Who delivers the Thursday morning re:Invent keynote?", 149 | "answer": "Werner Vogels", 150 | "answerType": "STRING", 151 | "alternativeAnswers": [ 152 | "Werner" 153 | ] 154 | }, 155 | { 156 | "id": 14, 157 | "points": 200, 158 | "question": "What sports star made a surprise experience at Midnight Madness in 2017?", 159 | "answer": "Shaq", 160 | "answerType": "STRING", 161 | "alternativeAnswers": [ 162 | "Shaquille O'Neal", 163 | "DJ Shaquille O'Neal", 164 | "DJ Diesel", 165 | "Diesel", 166 | "DJ Shaq" 167 | ] 168 | }, 169 | { 170 | "id": 15, 171 | "points": 300, 172 | "question": "What restaurant is located in both the Venetian and the Palazzo?", 173 | "answer": "Grand Lux Cafe", 174 | "answerType": "STRING", 175 | "alternativeAnswers": [ 176 | ] 177 | }, 178 | { 179 | "id": 16, 180 | "points": 400, 181 | "question": "What AWS speaker keynoted \"Tuesday Night Live\" in 2016?", 182 | "answer": "James Hamilton", 183 | "answerType": "STRING", 184 | "alternativeAnswers": [ 185 | ] 186 | } 187 | ] 188 | } 189 | ] 190 | -------------------------------------------------------------------------------- /trivia-backend/infra/cdk/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.d.ts 3 | node_modules 4 | cdk.json 5 | cdk.context.json 6 | cdk.out -------------------------------------------------------------------------------- /trivia-backend/infra/cdk/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | -------------------------------------------------------------------------------- /trivia-backend/infra/cdk/StackConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "Tags" : { 3 | "project" : "reinvent-trivia" 4 | } 5 | } -------------------------------------------------------------------------------- /trivia-backend/infra/cdk/buildspec-blue-green.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | env: 4 | variables: 5 | IMAGE_REPO_NAME: reinvent-trivia-backend 6 | 7 | phases: 8 | install: 9 | runtime-versions: 10 | nodejs: latest 11 | commands: 12 | # Install CDK & jq, upgrade npm 13 | - yum install -y jq 14 | - npm install -g aws-cdk 15 | 16 | pre_build: 17 | commands: 18 | # Set up environment variables like image tag and repo 19 | - cd $CODEBUILD_SRC_DIR/trivia-backend 20 | - export IMAGE_TAG=build-`echo $CODEBUILD_BUILD_ID | awk -F":" '{print $2}'` 21 | - AWS_ACCOUNT_ID=`echo $CODEBUILD_BUILD_ARN | awk -F":" '{print $5}'` 22 | - ECR_REPO=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME 23 | - aws ecr get-login-password | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com 24 | 25 | # Consume base image 26 | - export BASE_IMAGE=`jq -r '.ImageURI' <$CODEBUILD_SRC_DIR_BaseImage/imageDetail.json` 27 | - sed -i "s|reinvent-trivia-backend-base:release|$BASE_IMAGE|g" Dockerfile 28 | 29 | build: 30 | commands: 31 | # Build Docker image 32 | - docker build -t $IMAGE_REPO_NAME:$IMAGE_TAG . 33 | - docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $ECR_REPO:$IMAGE_TAG 34 | - docker push $ECR_REPO:$IMAGE_TAG 35 | 36 | # Synthesize CloudFormation templates 37 | - cd $CODEBUILD_SRC_DIR/trivia-backend/infra/cdk 38 | - npm ci 39 | - npm run build 40 | - cdk --no-version-reporting synth -o build --app 'node ecs-service-blue-green.js' 41 | - cp StackConfig.json build/ 42 | 43 | artifacts: 44 | files: 45 | - trivia-backend/infra/cdk/build/* 46 | discard-paths: yes 47 | -------------------------------------------------------------------------------- /trivia-backend/infra/cdk/buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | env: 4 | variables: 5 | IMAGE_REPO_NAME: reinvent-trivia-backend 6 | 7 | phases: 8 | install: 9 | runtime-versions: 10 | nodejs: latest 11 | commands: 12 | # Install CDK & jq, upgrade npm 13 | - yum install -y jq 14 | - npm install -g aws-cdk 15 | 16 | pre_build: 17 | commands: 18 | # Set up environment variables like image tag and repo 19 | - cd $CODEBUILD_SRC_DIR/trivia-backend 20 | - export IMAGE_TAG=build-`echo $CODEBUILD_BUILD_ID | awk -F":" '{print $2}'` 21 | - AWS_ACCOUNT_ID=`echo $CODEBUILD_BUILD_ARN | awk -F":" '{print $5}'` 22 | - ECR_REPO=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME 23 | - aws ecr get-login-password | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com 24 | 25 | # Consume base image 26 | - export BASE_IMAGE=`jq -r '.ImageURI' <$CODEBUILD_SRC_DIR_BaseImage/imageDetail.json` 27 | - sed -i "s|reinvent-trivia-backend-base:release|$BASE_IMAGE|g" Dockerfile 28 | 29 | build: 30 | commands: 31 | # Build Docker image 32 | - docker build -t $IMAGE_REPO_NAME:$IMAGE_TAG . 33 | - docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $ECR_REPO:$IMAGE_TAG 34 | - docker push $ECR_REPO:$IMAGE_TAG 35 | 36 | # Synthesize CloudFormation templates 37 | - cd $CODEBUILD_SRC_DIR/trivia-backend/infra/cdk 38 | - npm ci 39 | - npm run build 40 | - cdk synth -o build --app 'node ecs-service.js' 41 | - cp StackConfig.json build/ 42 | 43 | artifacts: 44 | files: 45 | - trivia-backend/infra/cdk/build/* 46 | discard-paths: yes 47 | -------------------------------------------------------------------------------- /trivia-backend/infra/cdk/ecs-service.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { App, Duration, Stack, StackProps } from 'aws-cdk-lib'; 3 | import { 4 | aws_certificatemanager as acm, 5 | aws_cloudwatch as cloudwatch, 6 | aws_ec2 as ec2, 7 | aws_ecr as ecr, 8 | aws_ecs as ecs, 9 | aws_ecs_patterns as patterns, 10 | aws_elasticloadbalancingv2 as elb, 11 | aws_route53 as route53, 12 | aws_ssm as ssm, 13 | } from 'aws-cdk-lib'; 14 | 15 | interface TriviaBackendStackProps extends StackProps { 16 | domainName: string; 17 | domainZone: string; 18 | } 19 | 20 | class TriviaBackendStack extends Stack { 21 | constructor(parent: App, name: string, props: TriviaBackendStackProps) { 22 | super(parent, name, props); 23 | 24 | // Network infrastructure 25 | const vpc = new ec2.Vpc(this, 'VPC', { maxAzs: 2 }); 26 | const cluster = new ecs.Cluster(this, 'Cluster', { 27 | clusterName: props.domainName.replace(/\./g, '-'), 28 | vpc, 29 | containerInsights: true 30 | }); 31 | 32 | // Configuration parameters 33 | const domainZone = route53.HostedZone.fromLookup(this, 'Zone', { domainName: props.domainZone }); 34 | const imageRepo = ecr.Repository.fromRepositoryName(this, 'Repo', 'reinvent-trivia-backend'); 35 | const tag = (process.env.IMAGE_TAG) ? process.env.IMAGE_TAG : 'latest'; 36 | const image = ecs.ContainerImage.fromEcrRepository(imageRepo, tag) 37 | 38 | // Lookup pre-existing TLS certificate 39 | const certificateArn = ssm.StringParameter.fromStringParameterAttributes(this, 'CertArnParameter', { 40 | parameterName: 'CertificateArn-' + props.domainName 41 | }).stringValue; 42 | const certificate = acm.Certificate.fromCertificateArn(this, 'Cert', certificateArn); 43 | 44 | // Fargate service + load balancer 45 | const service = new patterns.ApplicationLoadBalancedFargateService(this, 'Service', { 46 | cluster, 47 | taskImageOptions: { image }, 48 | desiredCount: 3, 49 | domainName: props.domainName, 50 | domainZone, 51 | certificate, 52 | propagateTags: ecs.PropagatedTagSource.SERVICE, 53 | }); 54 | 55 | // Alarms: monitor 500s and unhealthy hosts on target groups 56 | new cloudwatch.Alarm(this, 'TargetGroupUnhealthyHosts', { 57 | alarmName: this.stackName + '-Unhealthy-Hosts', 58 | metric: service.targetGroup.metricUnhealthyHostCount(), 59 | threshold: 1, 60 | evaluationPeriods: 2, 61 | }); 62 | 63 | new cloudwatch.Alarm(this, 'TargetGroup5xx', { 64 | alarmName: this.stackName + '-Http-500', 65 | metric: service.targetGroup.metricHttpCodeTarget( 66 | elb.HttpCodeTarget.TARGET_5XX_COUNT, 67 | { period: Duration.minutes(1) } 68 | ), 69 | threshold: 1, 70 | evaluationPeriods: 1, 71 | }); 72 | } 73 | } 74 | 75 | const app = new App(); 76 | new TriviaBackendStack(app, 'TriviaBackendTest', { 77 | domainName: 'api-test.reinvent-trivia.com', 78 | domainZone: 'reinvent-trivia.com', 79 | env: { account: process.env['CDK_DEFAULT_ACCOUNT'], region: 'us-east-1' }, 80 | tags: { 81 | project: "reinvent-trivia" 82 | } 83 | }); 84 | new TriviaBackendStack(app, 'TriviaBackendProd', { 85 | domainName: 'api.reinvent-trivia.com', 86 | domainZone: 'reinvent-trivia.com', 87 | env: { account: process.env['CDK_DEFAULT_ACCOUNT'], region: 'us-east-1' }, 88 | tags: { 89 | project: "reinvent-trivia" 90 | } 91 | }); 92 | app.synth(); 93 | -------------------------------------------------------------------------------- /trivia-backend/infra/cdk/ecs-task-sets.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { App, CfnOutput, Duration, Stack, StackProps } from 'aws-cdk-lib'; 3 | import { 4 | aws_ec2 as ec2, 5 | aws_ecr as ecr, 6 | aws_ecs as ecs, 7 | aws_elasticloadbalancingv2 as elb, 8 | } from 'aws-cdk-lib'; 9 | 10 | 11 | class TriviaBackendStack extends Stack { 12 | constructor(parent: App, name: string, props: StackProps) { 13 | super(parent, name, props); 14 | 15 | // Configuration parameters 16 | const imageRepo = ecr.Repository.fromRepositoryName(this, 'Repo', 'reinvent-trivia-backend'); 17 | const tag = (process.env.IMAGE_TAG) ? process.env.IMAGE_TAG : 'latest'; 18 | const image = ecs.ContainerImage.fromEcrRepository(imageRepo, tag) 19 | 20 | // Look up existing network infrastructure (default VPC) 21 | const vpc = ec2.Vpc.fromLookup(this, 'Vpc', { 22 | isDefault: true, 23 | }); 24 | const subnets = vpc.publicSubnets; 25 | const cluster = ecs.Cluster.fromClusterAttributes(this, 'Cluster', { 26 | clusterName: 'default', 27 | vpc, 28 | securityGroups: [], 29 | }); 30 | 31 | // Create load balancer and security group resources 32 | const serviceSG = new ec2.SecurityGroup(this, 'ServiceSecurityGroup', { vpc }); 33 | 34 | const loadBalancer = new elb.ApplicationLoadBalancer(this, 'LB', { 35 | vpc, 36 | internetFacing: true, 37 | }); 38 | serviceSG.connections.allowFrom(loadBalancer, ec2.Port.tcp(80)); 39 | new CfnOutput(this, 'ServiceURL', { value: 'http://' + loadBalancer.loadBalancerDnsName }); 40 | 41 | const listener = loadBalancer.addListener('PublicListener', { 42 | protocol: elb.ApplicationProtocol.HTTP, 43 | port: 80, 44 | open: true, 45 | }); 46 | const targetGroup = listener.addTargets('ECS', { 47 | protocol: elb.ApplicationProtocol.HTTP, 48 | deregistrationDelay: Duration.seconds(5), 49 | healthCheck: { 50 | interval: Duration.seconds(5), 51 | path: '/', 52 | protocol: elb.Protocol.HTTP, 53 | healthyThresholdCount: 2, 54 | unhealthyThresholdCount: 3, 55 | timeout: Duration.seconds(4) 56 | }, 57 | targets: [ // empty to begin with, set the target type to be 'IP' 58 | new (class EmptyIpTarget implements elb.IApplicationLoadBalancerTarget { 59 | attachToApplicationTargetGroup(_: elb.ApplicationTargetGroup): elb.LoadBalancerTargetProps { 60 | return { targetType: elb.TargetType.IP }; 61 | } 62 | })() 63 | ], 64 | }); 65 | 66 | // Create Fargate resources: task definition, service, task set, etc 67 | const taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDefinition', {}); 68 | const container = taskDefinition.addContainer('web', { 69 | image, 70 | logging: new ecs.AwsLogDriver({ streamPrefix: 'Service' }), 71 | }); 72 | container.addPortMappings({ containerPort: 80 }); 73 | 74 | const service = new ecs.CfnService(this, 'Service', { 75 | cluster: cluster.clusterName, 76 | desiredCount: 2, 77 | deploymentController: { type: ecs.DeploymentControllerType.EXTERNAL }, 78 | }); 79 | service.node.addDependency(targetGroup); 80 | service.node.addDependency(listener); 81 | 82 | const taskSet = new ecs.CfnTaskSet(this, 'TaskSet', { 83 | cluster: cluster.clusterName, 84 | service: service.attrName, 85 | scale: { unit: 'PERCENT', value: 100 }, 86 | taskDefinition: taskDefinition.taskDefinitionArn, 87 | launchType: ecs.LaunchType.FARGATE.toString(), 88 | loadBalancers: [ 89 | { 90 | containerName: 'web', 91 | containerPort: 80, 92 | targetGroupArn: targetGroup.targetGroupArn, 93 | } 94 | ], 95 | networkConfiguration: { 96 | awsVpcConfiguration: { 97 | assignPublicIp: 'ENABLED', 98 | securityGroups: [ serviceSG.securityGroupId ], 99 | subnets: subnets.map(subnet => subnet.subnetId), 100 | } 101 | }, 102 | }); 103 | 104 | new ecs.CfnPrimaryTaskSet(this, 'PrimaryTaskSet', { 105 | cluster: cluster.clusterName, 106 | service: service.attrName, 107 | taskSetId: taskSet.attrId, 108 | }); 109 | 110 | } 111 | } 112 | 113 | const app = new App(); 114 | new TriviaBackendStack(app, 'TriviaBackendTaskSets', { 115 | env: { account: process.env['CDK_DEFAULT_ACCOUNT'], region: 'us-east-1' } 116 | }); 117 | app.synth(); 118 | -------------------------------------------------------------------------------- /trivia-backend/infra/cdk/eks/alb-ingress-controller-policy.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from 'constructs'; 2 | import { aws_iam as iam } from 'aws-cdk-lib'; 3 | 4 | /** 5 | * From: https://raw.githubusercontent.com/kubernetes-sigs/aws-alb-ingress-controller/v1.1.4/docs/examples/iam-policy.json 6 | */ 7 | export class AlbIngressControllerPolicy extends iam.ManagedPolicy { 8 | constructor(parent: Construct, id: string) { 9 | super(parent, id, { 10 | managedPolicyName: 'AlbIngressControllerPolicy', 11 | description: 'Used by the ALB Ingress Controller pod to make AWS API calls', 12 | statements: [ 13 | new iam.PolicyStatement({ 14 | resources: ['*'], effect: iam.Effect.ALLOW, actions: [ 15 | "acm:DescribeCertificate", 16 | "acm:ListCertificates", 17 | "acm:GetCertificate" 18 | ] 19 | }), 20 | new iam.PolicyStatement({ 21 | resources: ['*'], effect: iam.Effect.ALLOW, actions: [ 22 | "ec2:AuthorizeSecurityGroupIngress", 23 | "ec2:CreateSecurityGroup", 24 | "ec2:CreateTags", 25 | "ec2:DeleteTags", 26 | "ec2:DeleteSecurityGroup", 27 | "ec2:DescribeAccountAttributes", 28 | "ec2:DescribeAddresses", 29 | "ec2:DescribeInstances", 30 | "ec2:DescribeInstanceStatus", 31 | "ec2:DescribeInternetGateways", 32 | "ec2:DescribeNetworkInterfaces", 33 | "ec2:DescribeSecurityGroups", 34 | "ec2:DescribeSubnets", 35 | "ec2:DescribeTags", 36 | "ec2:DescribeVpcs", 37 | "ec2:ModifyInstanceAttribute", 38 | "ec2:ModifyNetworkInterfaceAttribute", 39 | "ec2:RevokeSecurityGroupIngress" 40 | ] 41 | }), 42 | new iam.PolicyStatement({ 43 | resources: ['*'], effect: iam.Effect.ALLOW, actions: [ 44 | "elasticloadbalancing:AddListenerCertificates", 45 | "elasticloadbalancing:AddTags", 46 | "elasticloadbalancing:CreateListener", 47 | "elasticloadbalancing:CreateLoadBalancer", 48 | "elasticloadbalancing:CreateRule", 49 | "elasticloadbalancing:CreateTargetGroup", 50 | "elasticloadbalancing:DeleteListener", 51 | "elasticloadbalancing:DeleteLoadBalancer", 52 | "elasticloadbalancing:DeleteRule", 53 | "elasticloadbalancing:DeleteTargetGroup", 54 | "elasticloadbalancing:DeregisterTargets", 55 | "elasticloadbalancing:DescribeListenerCertificates", 56 | "elasticloadbalancing:DescribeListeners", 57 | "elasticloadbalancing:DescribeLoadBalancers", 58 | "elasticloadbalancing:DescribeLoadBalancerAttributes", 59 | "elasticloadbalancing:DescribeRules", 60 | "elasticloadbalancing:DescribeSSLPolicies", 61 | "elasticloadbalancing:DescribeTags", 62 | "elasticloadbalancing:DescribeTargetGroups", 63 | "elasticloadbalancing:DescribeTargetGroupAttributes", 64 | "elasticloadbalancing:DescribeTargetHealth", 65 | "elasticloadbalancing:ModifyListener", 66 | "elasticloadbalancing:ModifyLoadBalancerAttributes", 67 | "elasticloadbalancing:ModifyRule", 68 | "elasticloadbalancing:ModifyTargetGroup", 69 | "elasticloadbalancing:ModifyTargetGroupAttributes", 70 | "elasticloadbalancing:RegisterTargets", 71 | "elasticloadbalancing:RemoveListenerCertificates", 72 | "elasticloadbalancing:RemoveTags", 73 | "elasticloadbalancing:SetIpAddressType", 74 | "elasticloadbalancing:SetSecurityGroups", 75 | "elasticloadbalancing:SetSubnets", 76 | "elasticloadbalancing:SetWebACL" 77 | ] 78 | }), 79 | new iam.PolicyStatement({ 80 | resources: ['*'], effect: iam.Effect.ALLOW, actions: [ 81 | "iam:CreateServiceLinkedRole", 82 | "iam:GetServerCertificate", 83 | "iam:ListServerCertificates" 84 | ] 85 | }), 86 | new iam.PolicyStatement({ 87 | resources: ['*'], effect: iam.Effect.ALLOW, actions: [ 88 | "cognito-idp:DescribeUserPoolClient" 89 | ] 90 | }), 91 | new iam.PolicyStatement({ 92 | resources: ['*'], effect: iam.Effect.ALLOW, actions: [ 93 | "waf-regional:GetWebACLForResource", 94 | "waf-regional:GetWebACL", 95 | "waf-regional:AssociateWebACL", 96 | "waf-regional:DisassociateWebACL" 97 | ] 98 | }), 99 | new iam.PolicyStatement({ 100 | resources: ['*'], effect: iam.Effect.ALLOW, actions: [ 101 | "tag:GetResources", 102 | "tag:TagResources" 103 | ] 104 | }), 105 | new iam.PolicyStatement({ 106 | resources: ['*'], effect: iam.Effect.ALLOW, actions: [ 107 | "waf:GetWebACL" 108 | ] 109 | }) 110 | ] 111 | }) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /trivia-backend/infra/cdk/eks/kubernetes-resources/reinvent-trivia.ts: -------------------------------------------------------------------------------- 1 | import { Construct } from 'constructs'; 2 | import { 3 | aws_certificatemanager as acm, 4 | aws_ecs as ecs, 5 | aws_eks as eks, 6 | } from 'aws-cdk-lib'; 7 | 8 | /** 9 | * Properties for ReinventTriviaResources 10 | */ 11 | export interface ReinventTriviaResourceProps { 12 | /** 13 | * Reference to the ACM certificate 14 | */ 15 | readonly certificate: acm.ICertificate; 16 | /** 17 | * The EKS cluster to apply this configuration to. 18 | */ 19 | readonly cluster: eks.Cluster; 20 | 21 | /** 22 | * The domain name to use for the API. 23 | */ 24 | readonly domainName: string; 25 | 26 | /** 27 | * Reference to the existing container image from ECR. 28 | */ 29 | readonly image: ecs.EcrImage; 30 | } 31 | 32 | export class ReinventTriviaResource extends eks.KubernetesManifest { 33 | constructor(parent: Construct, id: string, props: ReinventTriviaResourceProps) { 34 | const manifest = [ 35 | { 36 | "apiVersion": "v1", 37 | "kind": "Namespace", 38 | "metadata": { 39 | "name": "reinvent-trivia" 40 | } 41 | }, 42 | { 43 | "apiVersion": "extensions/v1beta1", 44 | "kind": "Deployment", 45 | "metadata": { 46 | "name": "api", 47 | "namespace": "reinvent-trivia" 48 | }, 49 | "spec": { 50 | "replicas": 1, 51 | "template": { 52 | "metadata": { 53 | "labels": { 54 | "app": "api" 55 | } 56 | }, 57 | "spec": { 58 | "containers": [ 59 | { 60 | "image": props.image.imageName, 61 | "imagePullPolicy": "Always", 62 | "name": "api", 63 | "resources": { 64 | "requests": { 65 | "cpu": "375m", 66 | "memory": "1536Mi" 67 | } 68 | }, 69 | "ports": [ 70 | { 71 | "containerPort": 80 72 | } 73 | ], 74 | "env": [ 75 | { 76 | "name": "KUBE_NODE_NAME", 77 | "valueFrom": { 78 | "fieldRef": { 79 | "fieldPath": "spec.nodeName" 80 | } 81 | } 82 | }, 83 | { 84 | "name": "KUBE_POD_NAME", 85 | "valueFrom": { 86 | "fieldRef": { 87 | "fieldPath": "metadata.name" 88 | } 89 | } 90 | } 91 | ] 92 | } 93 | ] 94 | } 95 | } 96 | } 97 | }, 98 | { 99 | "apiVersion": "v1", 100 | "kind": "Service", 101 | "metadata": { 102 | "name": "api", 103 | "namespace": "reinvent-trivia", 104 | }, 105 | "spec": { 106 | "ports": [ 107 | { 108 | "port": 80, 109 | "targetPort": 80, 110 | "protocol": "TCP" 111 | } 112 | ], 113 | "type": "NodePort", 114 | "selector": { 115 | "app": "api" 116 | } 117 | } 118 | }, 119 | { 120 | "apiVersion": "extensions/v1beta1", 121 | "kind": "Ingress", 122 | "metadata": { 123 | "name": "api", 124 | "namespace": "reinvent-trivia", 125 | "annotations": { 126 | "kubernetes.io/ingress.class": "alb", 127 | "alb.ingress.kubernetes.io/scheme": "internet-facing", 128 | "alb.ingress.kubernetes.io/target-type": "ip", 129 | "alb.ingress.kubernetes.io/certificate-arn": props.certificate.certificateArn, 130 | "external-dns.alpha.kubernetes.io/hostname": props.domainName, 131 | }, 132 | "labels": { 133 | "app": "api" 134 | } 135 | }, 136 | "spec": { 137 | "rules": [ 138 | { 139 | "http": { 140 | "paths": [ 141 | { 142 | "path": "/*", 143 | "backend": { 144 | "serviceName": "api", 145 | "servicePort": 80 146 | } 147 | } 148 | ] 149 | } 150 | } 151 | ] 152 | } 153 | } 154 | ] 155 | 156 | super(parent, id, {cluster: props.cluster, manifest}) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /trivia-backend/infra/cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ecs-service-model", 3 | "version": "0.1.0", 4 | "main": "ecs-service.js", 5 | "types": "ecs-service.d.ts", 6 | "scripts": { 7 | "build": "tsc", 8 | "watch": "tsc -w", 9 | "cdk": "cdk", 10 | "synth-service": "cdk synth -o build --app 'node ecs-service.js'", 11 | "deploy-service": "cdk deploy --app 'node ecs-service.js'" 12 | }, 13 | "devDependencies": { 14 | "@types/node": "^22.15.29", 15 | "aws-cdk": "^2.1017.1", 16 | "typescript": "^5.8.3" 17 | }, 18 | "dependencies": { 19 | "@aws-cdk/lambda-layer-kubectl-v32": "^2.1.0", 20 | "aws-cdk-lib": "^2.199.0", 21 | "constructs": "^10.4.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /trivia-backend/infra/cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target":"ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2016", "es2017.object", "es2017.string"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": false, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization":false 20 | }, 21 | "exclude": ["cdk.out"] 22 | } 23 | -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-blue-green/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | *.js 3 | *.d.ts 4 | node_modules 5 | cdk.json 6 | cdk.context.json 7 | cdk.out 8 | -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-blue-green/appspec.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 0.0, 3 | "Resources": [ 4 | { 5 | "TargetService": { 6 | "Type": "AWS::ECS::Service", 7 | "Properties": { 8 | "TaskDefinition": "", 9 | "LoadBalancerInfo": { 10 | "ContainerName": "web", 11 | "ContainerPort": 80 12 | }, 13 | "PlatformVersion": "LATEST", 14 | "NetworkConfiguration": { 15 | "awsvpcConfiguration": { 16 | "subnets": [ 17 | "PLACEHOLDER_SUBNET" 18 | ], 19 | "securityGroups": [ 20 | "PLACEHOLDER_SECURITY_GROUP" 21 | ], 22 | "assignPublicIp": "DISABLED" 23 | } 24 | } 25 | } 26 | } 27 | } 28 | ], 29 | "Hooks": [ 30 | { 31 | "AfterAllowTestTraffic": "PLACEHOLDER_HOOK_FUNCTION" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-blue-green/buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | env: 4 | variables: 5 | IMAGE_REPO_NAME: reinvent-trivia-backend 6 | 7 | phases: 8 | install: 9 | runtime-versions: 10 | nodejs: latest 11 | commands: 12 | - yum install -y jq 13 | 14 | pre_build: 15 | commands: 16 | # Set up environment variables like image tag and repo 17 | - cd $CODEBUILD_SRC_DIR/trivia-backend 18 | - export IMAGE_TAG=build-`echo $CODEBUILD_BUILD_ID | awk -F":" '{print $2}'` 19 | - AWS_ACCOUNT_ID=`echo $CODEBUILD_BUILD_ARN | awk -F":" '{print $5}'` 20 | - ECR_REPO=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME 21 | - aws ecr get-login-password | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com 22 | 23 | # Consume base image from the pipeline 24 | - export BASE_IMAGE=`jq -r '.ImageURI' <$CODEBUILD_SRC_DIR_BaseImage/imageDetail.json` 25 | - sed -i "s|reinvent-trivia-backend-base:release|$BASE_IMAGE|g" Dockerfile 26 | 27 | build: 28 | commands: 29 | # Build and push Docker image 30 | - docker build -t $IMAGE_REPO_NAME:$IMAGE_TAG . 31 | - docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $ECR_REPO:$IMAGE_TAG 32 | - docker push $ECR_REPO:$IMAGE_TAG 33 | 34 | # Save the Docker image digest 35 | - IMAGE_URI=`docker inspect --format='{{index .RepoDigests 0}}' $ECR_REPO:$IMAGE_TAG` 36 | - echo Built image $IMAGE_URI, pushed with tag $IMAGE_TAG 37 | - echo "{\"ImageURI\":\"$IMAGE_URI\"}" > imageDetail.json 38 | 39 | # Generate appspec and task definition files (filling in subnet IDs, security group IDs, etc) 40 | - cd infra/codedeploy-blue-green 41 | - mkdir build 42 | - npm ci 43 | - node produce-config.js -g test -s TriviaBackendTest -h TriviaBackendHooksTest 44 | - node produce-config.js -g prod -s TriviaBackendProd -h TriviaBackendHooksProd 45 | 46 | artifacts: 47 | secondary-artifacts: 48 | BuildArtifact: 49 | files: 50 | - trivia-backend/infra/codedeploy-blue-green/build/appspec-prod.json 51 | - trivia-backend/infra/codedeploy-blue-green/build/appspec-test.json 52 | - trivia-backend/infra/codedeploy-blue-green/build/task-definition-test.json 53 | - trivia-backend/infra/codedeploy-blue-green/build/task-definition-prod.json 54 | discard-paths: yes 55 | ImageDetails: 56 | files: 57 | - trivia-backend/imageDetail.json 58 | discard-paths: yes 59 | -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-blue-green/deployment-setup.ts: -------------------------------------------------------------------------------- 1 | import { App, Duration, Fn, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib'; 2 | import { 3 | aws_cloudwatch as cloudwatch, 4 | aws_codedeploy as codedeploy, 5 | aws_ec2 as ec2, 6 | aws_ecr as ecr, 7 | aws_ecs as ecs, 8 | aws_elasticloadbalancingv2 as elbv2, 9 | aws_ssm as ssm, 10 | } from 'aws-cdk-lib'; 11 | 12 | interface TriviaDeploymentResourcesStackProps extends StackProps { 13 | infrastructureStackName: string; 14 | } 15 | 16 | /** 17 | * Set up the resources needed to do blue-green deployments, including the ECS service and CodeDeploy deployment group. 18 | * This stack is effectively "create-only": once the ECS service is created, it cannot be updated through CloudFormation, 19 | * only through CodeDeploy. 20 | */ 21 | class TriviaDeploymentResourcesStack extends Stack { 22 | constructor(parent: App, name: string, props: TriviaDeploymentResourcesStackProps) { 23 | super(parent, name, props); 24 | 25 | // Lookup existing resources 26 | const repo = ecr.Repository.fromRepositoryName(this, 'Repo', 'reinvent-trivia-backend'); 27 | const ecsApplication = codedeploy.EcsApplication.fromEcsApplicationName( 28 | this, 29 | 'App', 30 | Fn.importValue(props.infrastructureStackName + 'CodeDeployApplication'), 31 | ); 32 | const vpcId = ssm.StringParameter.valueFromLookup(this, `/${props.infrastructureStackName}/VPC`); 33 | const vpc = ec2.Vpc.fromLookup(this, 'Vpc', { vpcId }); 34 | const cluster = ecs.Cluster.fromClusterAttributes(this, 'Cluster', { 35 | clusterName: 'default', 36 | vpc, 37 | securityGroups: [], 38 | }); 39 | const serviceSGId = ssm.StringParameter.valueFromLookup(this, `/${props.infrastructureStackName}/ServiceSecurityGroup`); 40 | const serviceSG = ec2.SecurityGroup.fromLookupById(this, 'ServiceSG', serviceSGId); 41 | const blueTG = elbv2.ApplicationTargetGroup.fromTargetGroupAttributes( 42 | this, 43 | 'BlueTG', 44 | {targetGroupArn: Fn.importValue(props.infrastructureStackName + 'BlueTargetGroup')}, 45 | ); 46 | const greenTG = elbv2.ApplicationTargetGroup.fromTargetGroupAttributes( 47 | this, 48 | 'GreenTG', 49 | {targetGroupArn: Fn.importValue(props.infrastructureStackName + 'GreenTargetGroup')}, 50 | ); 51 | const lbSecurityGroup = ec2.SecurityGroup.fromSecurityGroupId( 52 | this, 53 | 'LBSecurityGroup', 54 | Fn.importValue(props.infrastructureStackName + 'LoadBalancerSecurityGroup'), 55 | { allowAllOutbound: true }, 56 | ); 57 | const prodListener = elbv2.ApplicationListener.fromApplicationListenerAttributes( 58 | this, 59 | 'ProdRoute', 60 | { 61 | listenerArn: Fn.importValue(props.infrastructureStackName + 'ProdTrafficListener'), 62 | securityGroup: lbSecurityGroup, 63 | }, 64 | ); 65 | const testListener = elbv2.ApplicationListener.fromApplicationListenerAttributes( 66 | this, 67 | 'TestRoute', 68 | { 69 | listenerArn: Fn.importValue(props.infrastructureStackName + 'TestTrafficListener'), 70 | securityGroup: lbSecurityGroup, 71 | }, 72 | ); 73 | const blueUnhealthyHostsAlarm = cloudwatch.Alarm.fromAlarmArn( 74 | this, 75 | 'BlueUnhealthyHostsAlarm', 76 | Fn.importValue(props.infrastructureStackName + 'BlueUnhealthyHostsAlarm'), 77 | ); 78 | const blueApiFailureAlarm = cloudwatch.Alarm.fromAlarmArn( 79 | this, 80 | 'BlueApiFailureAlarm', 81 | Fn.importValue(props.infrastructureStackName + 'BlueApiFailureAlarm'), 82 | ); 83 | const greenUnhealthyHostsAlarm = cloudwatch.Alarm.fromAlarmArn( 84 | this, 85 | 'GreenUnhealthyHostsAlarm', 86 | Fn.importValue(props.infrastructureStackName + 'GreenUnhealthyHostsAlarm'), 87 | ); 88 | const greenApiFailureAlarm = cloudwatch.Alarm.fromAlarmArn( 89 | this, 90 | 'GreenApiFailureAlarm', 91 | Fn.importValue(props.infrastructureStackName + 'GreenApiFailureAlarm'), 92 | ); 93 | 94 | // ECS resources 95 | const taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDef', { 96 | family: 'trivia-backend', 97 | }); 98 | taskDefinition.addContainer('Container', { 99 | containerName: 'web', 100 | image: ecs.ContainerImage.fromEcrRepository(repo, 'latest'), 101 | portMappings: [{ 102 | protocol: ecs.Protocol.TCP, 103 | containerPort: 80, 104 | hostPort: 80, 105 | }], 106 | }); 107 | const cfnTaskDef = taskDefinition.node.defaultChild as ecs.CfnTaskDefinition; 108 | cfnTaskDef.applyRemovalPolicy(RemovalPolicy.RETAIN, { applyToUpdateReplacePolicy: true }); 109 | 110 | const service = new ecs.FargateService(this, 'Service', { 111 | serviceName: props.infrastructureStackName, 112 | cluster, 113 | taskDefinition, 114 | securityGroups: [serviceSG], 115 | desiredCount: 3, 116 | deploymentController: { 117 | type: ecs.DeploymentControllerType.CODE_DEPLOY, 118 | }, 119 | propagateTags: ecs.PropagatedTagSource.SERVICE, 120 | }); 121 | service.attachToApplicationTargetGroup(blueTG); 122 | 123 | // CodeDeploy resources 124 | const deploymentConfig = codedeploy.EcsDeploymentConfig.fromEcsDeploymentConfigName(this, 'DC', 'CodeDeployDefault.ECSCanary10Percent5Minutes'); 125 | 126 | new codedeploy.EcsDeploymentGroup(this, 'DeploymentGroup', { 127 | application: ecsApplication, 128 | deploymentGroupName: 'DgpECS-' + props.infrastructureStackName, 129 | deploymentConfig, 130 | alarms: [ 131 | blueUnhealthyHostsAlarm, 132 | blueApiFailureAlarm, 133 | greenUnhealthyHostsAlarm, 134 | greenApiFailureAlarm, 135 | ], 136 | service, 137 | blueGreenDeploymentConfig: { 138 | blueTargetGroup: blueTG, 139 | greenTargetGroup: greenTG, 140 | listener: prodListener, 141 | testListener, 142 | terminationWaitTime: Duration.minutes(10), 143 | }, 144 | autoRollback: { 145 | stoppedDeployment: true, 146 | }, 147 | }); 148 | } 149 | } 150 | 151 | const app = new App(); 152 | new TriviaDeploymentResourcesStack(app, 'TriviaDeploymentResourcesTest', { 153 | infrastructureStackName: 'TriviaBackendTest', 154 | env: { account: process.env['CDK_DEFAULT_ACCOUNT'], region: 'us-east-1' }, 155 | tags: { 156 | project: "reinvent-trivia" 157 | } 158 | }); 159 | new TriviaDeploymentResourcesStack(app, 'TriviaDeploymentResourcesProd', { 160 | infrastructureStackName: 'TriviaBackendProd', 161 | env: { account: process.env['CDK_DEFAULT_ACCOUNT'], region: 'us-east-1' }, 162 | tags: { 163 | project: "reinvent-trivia" 164 | } 165 | }); 166 | app.synth(); 167 | -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-blue-green/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trivia-backend-blue-green", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "build": "tsc", 6 | "synth-infra": "tsc && cdk synth --app 'node infra-setup.js'", 7 | "deploy-test-infra": "tsc && cdk deploy --app 'node infra-setup.js' --require-approval never TriviaBackendTest", 8 | "deploy-prod-infra": "tsc && cdk deploy --app 'node infra-setup.js' --require-approval never TriviaBackendProd", 9 | "synth-deployment-resources": "tsc && cdk synth --app 'node deployment-setup.js'", 10 | "deploy-test-deployment-resources": "tsc && cdk deploy --app 'node deployment-setup.js' --require-approval never TriviaDeploymentResourcesTest", 11 | "deploy-prod-deployment-resources": "tsc && cdk deploy --app 'node deployment-setup.js' --require-approval never TriviaDeploymentResourcesProd" 12 | }, 13 | "devDependencies": { 14 | "@types/node": "^22.15.29", 15 | "typescript": "^5.8.3", 16 | "aws-cdk": "^2.1017.1" 17 | }, 18 | "dependencies": { 19 | "aws-cdk-lib": "^2.199.0", 20 | "aws-sdk": "^2.1692.0", 21 | "constructs": "^10.4.2", 22 | "yargs": "^18.0.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-blue-green/produce-config.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const aws = require('aws-sdk'); 4 | const fs = require('fs'); 5 | 6 | const argv = require('yargs') 7 | .usage('Create ECS/CodeDeploy config files with values from CloudFormation stacks\nUsage: $0') 8 | .demandOption(['s', 'd', 'g', 'h']) 9 | .alias('s', 'infra-stack-name') 10 | .alias('d', 'service-stack-name') 11 | .alias('g', 'stage-name') 12 | .alias('h', 'hook-stack-name') 13 | .argv; 14 | 15 | const taskDefConfig = require('./task-definition.json'); 16 | const appSpec = require('./appspec.json'); 17 | 18 | const stage = argv.stageName; 19 | const infraStack = argv.infraStackName; 20 | const serviceStack = argv.serviceStackName; 21 | const hookStack = argv.hookStackName; 22 | 23 | const cfn = new aws.CloudFormation(); 24 | 25 | async function produceConfigs() { 26 | let infraData = await cfn.describeStackResources({ StackName: infraStack }).promise(); 27 | let serviceData = await cfn.describeStackResources({ StackName: serviceStack }).promise(); 28 | let hookData = await cfn.describeStackResources({ StackName: hookStack }).promise(); 29 | 30 | // Make a whole bunch of assumptions about the contents of the CFN stacks 31 | let privateSubnets = []; 32 | let serviceSecurityGroups = []; 33 | let taskRole; 34 | let executionRole; 35 | let preTrafficHook; 36 | 37 | for (const resource of infraData.StackResources) { 38 | if (resource.ResourceType == 'AWS::EC2::Subnet' && 39 | resource.LogicalResourceId.startsWith('VPCPrivateSubnet')) { 40 | privateSubnets.push(resource.PhysicalResourceId); 41 | } else if (resource.ResourceType == 'AWS::EC2::SecurityGroup' && 42 | resource.LogicalResourceId.startsWith('ServiceSecurityGroup')) { 43 | serviceSecurityGroups.push(resource.PhysicalResourceId); 44 | } 45 | } 46 | 47 | for (const resource of serviceData.StackResources) { 48 | if (resource.ResourceType == 'AWS::IAM::Role' && 49 | resource.LogicalResourceId.startsWith('TaskDefExecutionRole')) { 50 | executionRole = resource.PhysicalResourceId; 51 | } else if (resource.ResourceType == 'AWS::IAM::Role' && 52 | resource.LogicalResourceId.startsWith('TaskDefTaskRole')) { 53 | taskRole = resource.PhysicalResourceId; 54 | } 55 | } 56 | 57 | for (const resource of hookData.StackResources) { 58 | if (resource.LogicalResourceId == 'PreTrafficHook') { 59 | preTrafficHook = resource.PhysicalResourceId; 60 | } 61 | } 62 | 63 | // Write out task def config 64 | taskDefConfig.taskRoleArn = taskRole; 65 | taskDefConfig.executionRoleArn = executionRole; 66 | fs.writeFileSync(`./build/task-definition-${stage}.json`, JSON.stringify(taskDefConfig, null, 2) , 'utf-8'); 67 | 68 | // Write out appspec 69 | appSpec.Resources[0].TargetService.Properties.NetworkConfiguration.awsvpcConfiguration.subnets = privateSubnets; 70 | appSpec.Resources[0].TargetService.Properties.NetworkConfiguration.awsvpcConfiguration.securityGroups = serviceSecurityGroups; 71 | appSpec.Hooks[0].AfterAllowTestTraffic = preTrafficHook; 72 | fs.writeFileSync(`./build/appspec-${stage}.json`, JSON.stringify(appSpec, null, 2) , 'utf-8'); 73 | } 74 | 75 | produceConfigs().catch(err => { 76 | console.error('There was an uncaught error', err); 77 | process.exit(1); 78 | }); 79 | -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-blue-green/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | # Provision infrastructure stack 6 | 7 | npm install 8 | 9 | npm run deploy-test-infra 10 | 11 | npm run deploy-prod-infra 12 | 13 | # Provision lifecycle event hooks 14 | 15 | cd ../codedeploy-lifecycle-event-hooks 16 | 17 | npm install 18 | 19 | aws cloudformation package --template-file template.yaml --s3-bucket $1 --output-template-file packaged-template.yaml 20 | 21 | aws cloudformation deploy --region us-east-1 --template-file packaged-template.yaml --stack-name TriviaBackendHooksTest --tags project=reinvent-trivia --capabilities CAPABILITY_NAMED_IAM --tags project=reinvent-trivia --parameter-overrides TriviaBackendDomain=api-test.reinvent-trivia.com 22 | 23 | aws cloudformation deploy --region us-east-1 --template-file packaged-template.yaml --stack-name TriviaBackendHooksProd --tags project=reinvent-trivia --capabilities CAPABILITY_NAMED_IAM --tags project=reinvent-trivia --parameter-overrides TriviaBackendDomain=api.reinvent-trivia.com 24 | 25 | cd ../codedeploy-blue-green 26 | 27 | # Provision ECS and CodeDeploy resources 28 | 29 | aws ecs create-cluster --region us-east-1 --cluster-name default --tags key=project,value=reinvent-trivia 30 | 31 | aws ecs update-cluster-settings --region us-east-1 --cluster default --settings name=containerInsights,value=enabled 32 | 33 | npm run deploy-test-deployment-resources 34 | 35 | npm run deploy-prod-deployment-resources 36 | 37 | # Generate task definition and appsec files 38 | 39 | mkdir -p build 40 | 41 | export AWS_REGION=us-east-1 42 | 43 | node produce-config.js -g test -s TriviaBackendTest -d TriviaDeploymentResourcesTest -h TriviaBackendHooksTest 44 | 45 | node produce-config.js -g prod -s TriviaBackendProd -d TriviaDeploymentResourcesProd -h TriviaBackendHooksProd 46 | 47 | ACCOUNT_ID=`aws sts get-caller-identity --query Account --output text --region us-east-1` 48 | 49 | sed -i "s||$ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/reinvent-trivia-backend:latest|g" build/task-definition-test.json build/task-definition-prod.json 50 | 51 | # Start deployment 52 | 53 | aws ecs deploy --region us-east-1 --service TriviaBackendTest --codedeploy-application AppECS-TriviaBackendTest --codedeploy-deployment-group DgpECS-TriviaBackendTest --task-definition build/task-definition-test.json --codedeploy-appspec build/appspec-test.json 54 | 55 | aws ecs deploy --region us-east-1 --service TriviaBackendTest --codedeploy-application AppECS-TriviaBackendProd --codedeploy-deployment-group Dgp-TriviaBackendProd --task-definition build/task-definition-prod.json --codedeploy-appspec build/appspec-prod.json 56 | -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-blue-green/task-definition.json: -------------------------------------------------------------------------------- 1 | { 2 | "family": "trivia-backend", 3 | "containerDefinitions": [ 4 | { 5 | "portMappings": [ 6 | { 7 | "hostPort": 80, 8 | "protocol": "tcp", 9 | "containerPort": 80 10 | } 11 | ], 12 | "image": "", 13 | "essential": true, 14 | "name": "web" 15 | } 16 | ], 17 | "cpu": "256", 18 | "memory": "512", 19 | "taskRoleArn": "PLACEHOLDER_TASK_ROLE", 20 | "executionRoleArn": "PLACEHOLDER_EXECUTION_ROLE", 21 | "requiresCompatibilities": [ 22 | "FARGATE" 23 | ], 24 | "networkMode": "awsvpc", 25 | "tags": [ 26 | { 27 | "key": "project", 28 | "value": "reinvent-trivia" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-blue-green/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target":"ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2016", "es2017.object", "es2017.string"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": false, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization":false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-lifecycle-event-hooks/.gitignore: -------------------------------------------------------------------------------- 1 | packaged-template.yaml -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-lifecycle-event-hooks/StackConfigProd.json: -------------------------------------------------------------------------------- 1 | { 2 | "Tags" : { 3 | "project" : "reinvent-trivia" 4 | }, 5 | "Parameters" : { 6 | "TriviaBackendDomain": "api.reinvent-trivia.com" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-lifecycle-event-hooks/StackConfigTest.json: -------------------------------------------------------------------------------- 1 | { 2 | "Tags" : { 3 | "project" : "reinvent-trivia" 4 | }, 5 | "Parameters" : { 6 | "TriviaBackendDomain": "api-test.reinvent-trivia.com" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-lifecycle-event-hooks/buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | runtime-versions: 6 | nodejs: latest 7 | commands: 8 | - cd trivia-backend/infra/codedeploy-lifecycle-event-hooks 9 | - npm ci 10 | build: 11 | commands: 12 | - aws cloudformation package --template-file template.yaml --s3-bucket $ARTIFACTS_BUCKET --output-template-file packaged-template.yaml 13 | - cp packaged-template.yaml TriviaGameHooksTest.template.yaml 14 | - cp packaged-template.yaml TriviaGameHooksProd.template.yaml 15 | 16 | artifacts: 17 | files: 18 | - trivia-backend/infra/codedeploy-lifecycle-event-hooks/TriviaGameHooksTest.template.yaml 19 | - trivia-backend/infra/codedeploy-lifecycle-event-hooks/TriviaGameHooksProd.template.yaml 20 | - trivia-backend/infra/codedeploy-lifecycle-event-hooks/StackConfigTest.json 21 | - trivia-backend/infra/codedeploy-lifecycle-event-hooks/StackConfigProd.json 22 | discard-paths: yes 23 | -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-lifecycle-event-hooks/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-trivia-game-backend-validation-hooks", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "aws-trivia-game-backend-validation-hooks", 9 | "version": "1.0.0", 10 | "dependencies": { 11 | "axios": "^1.8.2" 12 | } 13 | }, 14 | "node_modules/asynckit": { 15 | "version": "0.4.0", 16 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 17 | "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", 18 | "license": "MIT" 19 | }, 20 | "node_modules/axios": { 21 | "version": "1.8.2", 22 | "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", 23 | "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", 24 | "license": "MIT", 25 | "dependencies": { 26 | "follow-redirects": "^1.15.6", 27 | "form-data": "^4.0.0", 28 | "proxy-from-env": "^1.1.0" 29 | } 30 | }, 31 | "node_modules/combined-stream": { 32 | "version": "1.0.8", 33 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 34 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 35 | "license": "MIT", 36 | "dependencies": { 37 | "delayed-stream": "~1.0.0" 38 | }, 39 | "engines": { 40 | "node": ">= 0.8" 41 | } 42 | }, 43 | "node_modules/delayed-stream": { 44 | "version": "1.0.0", 45 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 46 | "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", 47 | "license": "MIT", 48 | "engines": { 49 | "node": ">=0.4.0" 50 | } 51 | }, 52 | "node_modules/follow-redirects": { 53 | "version": "1.15.9", 54 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", 55 | "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", 56 | "funding": [ 57 | { 58 | "type": "individual", 59 | "url": "https://github.com/sponsors/RubenVerborgh" 60 | } 61 | ], 62 | "license": "MIT", 63 | "engines": { 64 | "node": ">=4.0" 65 | }, 66 | "peerDependenciesMeta": { 67 | "debug": { 68 | "optional": true 69 | } 70 | } 71 | }, 72 | "node_modules/form-data": { 73 | "version": "4.0.1", 74 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", 75 | "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", 76 | "license": "MIT", 77 | "dependencies": { 78 | "asynckit": "^0.4.0", 79 | "combined-stream": "^1.0.8", 80 | "mime-types": "^2.1.12" 81 | }, 82 | "engines": { 83 | "node": ">= 6" 84 | } 85 | }, 86 | "node_modules/mime-db": { 87 | "version": "1.52.0", 88 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 89 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", 90 | "license": "MIT", 91 | "engines": { 92 | "node": ">= 0.6" 93 | } 94 | }, 95 | "node_modules/mime-types": { 96 | "version": "2.1.35", 97 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 98 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 99 | "license": "MIT", 100 | "dependencies": { 101 | "mime-db": "1.52.0" 102 | }, 103 | "engines": { 104 | "node": ">= 0.6" 105 | } 106 | }, 107 | "node_modules/proxy-from-env": { 108 | "version": "1.1.0", 109 | "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", 110 | "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", 111 | "license": "MIT" 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-lifecycle-event-hooks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-trivia-game-backend-validation-hooks", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "axios": "^1.8.2" 6 | }, 7 | "private": true 8 | } 9 | -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-lifecycle-event-hooks/pre-traffic-hook.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const aws = require('aws-sdk'); 4 | const axios = require('axios'); 5 | const codedeploy = new aws.CodeDeploy(); 6 | 7 | const TARGET_URL = process.env.TargetUrl; 8 | 9 | function sleep(seconds) { 10 | return new Promise(resolve => setTimeout(resolve, seconds * 1000)); 11 | } 12 | 13 | exports.handler = async function (event, context, callback) { 14 | 15 | console.log("Entering PreTraffic Hook!"); 16 | console.log(JSON.stringify(event)); 17 | 18 | // Read the DeploymentId from the event payload. 19 | var deploymentId = event.DeploymentId; 20 | console.log("Deployment: " + deploymentId); 21 | 22 | // Read the LifecycleEventHookExecutionId from the event payload 23 | var lifecycleEventHookExecutionId = event.LifecycleEventHookExecutionId; 24 | console.log("LifecycleEventHookExecutionId: " + lifecycleEventHookExecutionId); 25 | 26 | // Ensure test traffic is fully shifted over to new target group 27 | console.log("Waiting 30 seconds"); 28 | await sleep(30); 29 | 30 | // Prepare the validation test results with the deploymentId and 31 | // the lifecycleEventHookExecutionId for AWS CodeDeploy. 32 | var params = { 33 | deploymentId: deploymentId, 34 | lifecycleEventHookExecutionId: lifecycleEventHookExecutionId, 35 | status: 'Succeeded' 36 | }; 37 | 38 | // Perform validation or pre-warming steps. 39 | // Make a request to the target URL and check the response 40 | try { 41 | console.log("Target: " + TARGET_URL); 42 | const response = await axios(TARGET_URL); 43 | console.log("Response:"); 44 | console.log(response); 45 | if (response.status != 200) { 46 | console.error("Failure status"); 47 | params.status = 'Failed'; 48 | } else if (response.data.length != 4) { 49 | console.error("Wrong number of categories"); 50 | params.status = 'Failed'; 51 | } 52 | } catch (err) { 53 | console.error(err); 54 | params.status = 'Failed'; 55 | } 56 | 57 | // Pass AWS CodeDeploy the prepared validation test results. 58 | try { 59 | console.log(params); 60 | await codedeploy.putLifecycleEventHookExecutionStatus(params).promise(); 61 | console.log('Successfully reported hook results'); 62 | callback(null, 'Successfully reported hook results'); 63 | } catch (err) { 64 | console.error('Failed to report hook results'); 65 | console.error(err); 66 | callback('Failed to report hook results'); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /trivia-backend/infra/codedeploy-lifecycle-event-hooks/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: Validation hooks for trivia backend 4 | 5 | Parameters: 6 | TriviaBackendDomain: 7 | Type: String 8 | Default: 'api.reinvent-trivia.com' 9 | 10 | TriviaBackendTestPort: 11 | Type: Number 12 | Default: 9002 13 | 14 | Resources: 15 | PreTrafficHook: 16 | Type: AWS::Serverless::Function 17 | Properties: 18 | FunctionName: !Join 19 | - '-' 20 | - - 'CodeDeployHook_' 21 | - !Ref "AWS::StackName" 22 | - 'pre-traffic-hook' 23 | CodeUri: ./ 24 | Timeout: 300 25 | Handler: pre-traffic-hook.handler 26 | Policies: 27 | - Version: "2012-10-17" 28 | Statement: 29 | - Effect: "Allow" 30 | Action: 31 | - "codedeploy:PutLifecycleEventHookExecutionStatus" 32 | - "codedeploy:CreateCloudFormationDeployment" 33 | Resource: 34 | !Sub 'arn:${AWS::Partition}:codedeploy:${AWS::Region}:${AWS::AccountId}:deploymentgroup:*/*' 35 | Runtime: nodejs14.x 36 | DeploymentPreference: 37 | Enabled: false 38 | Role: "" 39 | Environment: 40 | Variables: 41 | TargetUrl: !Join # Example: https://api.reinvent-trivia.com:9002/api/trivia/all 42 | - '' 43 | - - 'https://' 44 | - !Ref TriviaBackendDomain 45 | - ':' 46 | - !Ref TriviaBackendTestPort 47 | - '/api/trivia/all' 48 | 49 | CodeDeployHookRole: 50 | Type: AWS::IAM::Role 51 | Properties: 52 | RoleName: !Sub 'CodeDeployHookRole_${AWS::StackName}' 53 | AssumeRolePolicyDocument: 54 | Statement: 55 | - Effect: Allow 56 | Principal: 57 | Service: 58 | - codedeploy.amazonaws.com 59 | Action: 60 | - sts:AssumeRole 61 | Path: / 62 | Policies: 63 | - PolicyName: "Invoke-Hooks" 64 | PolicyDocument: 65 | Statement: 66 | - Effect: Allow 67 | Action: 68 | - "lambda:InvokeFunction" 69 | Resource: !Sub 'arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:CodeDeployHook_*' 70 | --------------------------------------------------------------------------------