├── .eslintrc.json ├── .gitattributes ├── .github ├── pull_request_template.md └── workflows │ ├── build.yml │ ├── pull-request-lint.yml │ ├── release.yml │ └── upgrade-master.yml ├── .gitignore ├── .mergify.yml ├── .npmignore ├── .projen ├── deps.json ├── files.json └── tasks.json ├── .projenrc.js ├── API.md ├── LICENSE ├── README.md ├── assets ├── codecommit-mirror │ └── docker │ │ ├── Dockerfile │ │ └── mirror.sh └── toolkit-cleaner │ └── docker │ ├── Dockerfile │ └── dummy.txt ├── package.json ├── src ├── codecommit-mirror │ ├── README.md │ └── index.ts ├── dmarc │ ├── README.md │ └── index.ts ├── ecs-service-roller │ ├── README.md │ └── index.ts ├── email-receiver │ ├── README.md │ ├── index.ts │ ├── receiver.ts │ ├── whitelist-function.ts │ └── whitelist.lambda.ts ├── index.ts ├── mjml-template │ ├── README.md │ └── index.ts ├── saml-identity-provider │ ├── README.md │ └── index.ts ├── slack-app │ ├── README.md │ ├── index.ts │ ├── manifest.ts │ ├── provider-function.ts │ ├── provider.lambda.ts │ ├── provider.ts │ └── slack-app.ts ├── slack-events │ ├── README.md │ ├── events-function.ts │ ├── events.lambda.ts │ ├── index.ts │ └── signature.ts ├── slack-textract │ ├── README.md │ ├── detect-function.ts │ ├── detect.lambda.ts │ ├── index.ts │ └── slack-textract.gif ├── ssl-server-test │ ├── README.md │ ├── analyze-function.ts │ ├── analyze.lambda.ts │ ├── extract-grade-function.ts │ ├── extract-grade.lambda.ts │ ├── index.ts │ └── ssl-server-test.svg ├── state-machine-cr-provider │ ├── README.md │ ├── index.ts │ └── runtime │ │ ├── http.ts │ │ └── index.ts ├── static-website │ ├── README.md │ ├── index.ts │ ├── origin-request-function.ts │ └── origin-request.edge-lambda.ts ├── toolkit-cleaner │ ├── README.md │ ├── clean-images-function.ts │ ├── clean-images.lambda.ts │ ├── clean-objects-function.ts │ ├── clean-objects.lambda.ts │ ├── extract-template-hashes-function.ts │ ├── extract-template-hashes.lambda.ts │ ├── get-stack-names-function.ts │ ├── get-stack-names.lambda.ts │ ├── index.ts │ └── toolkit-cleaner.svg └── url-shortener │ ├── README.md │ ├── index.ts │ ├── redirect-function.ts │ ├── redirect.edge-lambda.ts │ ├── shortener-function.ts │ └── shortener.lambda.ts ├── test ├── codecommit-mirror │ ├── __snapshots__ │ │ └── codecommit-mirror.test.ts.snap │ └── codecommit-mirror.test.ts ├── dmarc │ ├── __snapshots__ │ │ └── dmarc-reporter.test.ts.snap │ └── dmarc-reporter.test.ts ├── ecs-service-roller │ ├── __snapshots__ │ │ └── ecs-service-roller.test.ts.snap │ └── ecs-service-roller.test.ts ├── email-receiver │ ├── __snapshots__ │ │ └── email-receiver.test.ts.snap │ ├── email-receiver.test.ts │ └── whitelist-handler.test.ts ├── mjml-template │ ├── __snapshots__ │ │ └── mjml-template.test.ts.snap │ ├── mjml-template.integ.snapshot │ │ ├── mjml-template-integ.assets.json │ │ └── mjml-template-integ.template.json │ ├── mjml-template.integ.ts │ └── mjml-template.test.ts ├── saml-identity-provider │ ├── __snapshots__ │ │ └── saml-identity-provider.test.ts.snap │ └── saml-identity-provider.test.ts ├── slack-app │ ├── __snapshots__ │ │ └── slack-app.test.ts.snap │ ├── handler.test.ts │ ├── slack-app.integ.snapshot │ │ ├── slack-app-integ.assets.json │ │ └── slack-app-integ.template.json │ ├── slack-app.integ.ts │ └── slack-app.test.ts ├── slack-events │ ├── __snapshots__ │ │ └── slack-events.test.ts.snap │ ├── handler.test.ts │ ├── signature.test.ts │ └── slack-events.test.ts ├── slack-textract │ ├── __snapshots__ │ │ └── slack-textract.test.ts.snap │ ├── handler.test.ts │ └── slack-textract.test.ts ├── ssl-server-test │ ├── analyze.lambda.test.ts │ ├── extract-grade.lambda.test.ts │ ├── ssl-server-test.integ.snapshot │ │ ├── ssl-server-test-integ.assets.json │ │ └── ssl-server-test-integ.template.json │ ├── ssl-server-test.integ.ts │ └── ssl-server-test.test.ts ├── state-machine-cr-provider │ ├── __snapshots__ │ │ └── state-machine-cr-provider.test.ts.snap │ ├── http.test.ts │ ├── runtime.test.ts │ └── state-machine-cr-provider.test.ts ├── static-website │ ├── __snapshots__ │ │ └── static-website.test.ts.snap │ ├── origin-request-handler.test.ts │ └── static-website.test.ts ├── toolkit-cleaner │ ├── clean-images.test.ts │ ├── clean-objects.test.ts │ ├── extract-template-hashes.test.ts │ ├── get-stack-names.test.ts │ ├── toolkit-cleaner.integ.snapshot │ │ ├── toolkit-cleaner-integ.assets.json │ │ └── toolkit-cleaner-integ.template.json │ ├── toolkit-cleaner.integ.ts │ └── toolkit-cleaner.test.ts └── url-shortener │ ├── __snapshots__ │ └── url-shortener.test.ts.snap │ ├── shortener.test.ts │ ├── url-shortener.integ.snapshot │ ├── edge-lambda-stack-c8e731c8ad0787291628b399d525c90dc78319b7d7.assets.json │ ├── edge-lambda-stack-c8e731c8ad0787291628b399d525c90dc78319b7d7.template.json │ ├── url-shortener-integ.assets.json │ └── url-shortener-integ.template.json │ ├── url-shortener.integ.ts │ └── url-shortener.test.ts ├── tsconfig.dev.json └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | * text=auto eol=lf 4 | *.snap linguist-generated 5 | /.eslintrc.json linguist-generated 6 | /.gitattributes linguist-generated 7 | /.github/pull_request_template.md linguist-generated 8 | /.github/workflows/build.yml linguist-generated 9 | /.github/workflows/pull-request-lint.yml linguist-generated 10 | /.github/workflows/release.yml linguist-generated 11 | /.github/workflows/upgrade-master.yml linguist-generated 12 | /.gitignore linguist-generated 13 | /.mergify.yml linguist-generated 14 | /.npmignore linguist-generated 15 | /.projen/** linguist-generated 16 | /.projen/deps.json linguist-generated 17 | /.projen/files.json linguist-generated 18 | /.projen/tasks.json linguist-generated 19 | /API.md linguist-generated 20 | /LICENSE linguist-generated 21 | /package.json linguist-generated 22 | /src/email-receiver/whitelist-function.ts linguist-generated 23 | /src/slack-app/provider-function.ts linguist-generated 24 | /src/slack-events/events-function.ts linguist-generated 25 | /src/slack-textract/detect-function.ts linguist-generated 26 | /src/ssl-server-test/analyze-function.ts linguist-generated 27 | /src/ssl-server-test/extract-grade-function.ts linguist-generated 28 | /src/static-website/origin-request-function.ts linguist-generated 29 | /src/toolkit-cleaner/clean-images-function.ts linguist-generated 30 | /src/toolkit-cleaner/clean-objects-function.ts linguist-generated 31 | /src/toolkit-cleaner/extract-template-hashes-function.ts linguist-generated 32 | /src/toolkit-cleaner/get-stack-names-function.ts linguist-generated 33 | /src/url-shortener/redirect-function.ts linguist-generated 34 | /src/url-shortener/shortener-function.ts linguist-generated 35 | /tsconfig.dev.json linguist-generated 36 | /yarn.lock linguist-generated -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Fixes # -------------------------------------------------------------------------------- /.github/workflows/pull-request-lint.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: pull-request-lint 4 | on: 5 | pull_request_target: 6 | types: 7 | - labeled 8 | - opened 9 | - synchronize 10 | - reopened 11 | - ready_for_review 12 | - edited 13 | merge_group: {} 14 | jobs: 15 | validate: 16 | name: Validate PR title 17 | runs-on: ubuntu-latest 18 | permissions: 19 | pull-requests: write 20 | if: (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') 21 | steps: 22 | - uses: amannn/action-semantic-pull-request@v5.4.0 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | with: 26 | types: |- 27 | feat 28 | fix 29 | chore 30 | requireScope: false 31 | -------------------------------------------------------------------------------- /.github/workflows/upgrade-master.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: upgrade-master 4 | on: 5 | workflow_dispatch: {} 6 | schedule: 7 | - cron: 0 0 * * * 8 | jobs: 9 | upgrade: 10 | name: Upgrade 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read 14 | outputs: 15 | patch_created: ${{ steps.create_patch.outputs.patch_created }} 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | ref: master 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: lts/* 25 | - name: Install dependencies 26 | run: yarn install --check-files --frozen-lockfile 27 | - name: Upgrade dependencies 28 | run: npx projen upgrade 29 | - name: Find mutations 30 | id: create_patch 31 | run: |- 32 | git add . 33 | git diff --staged --patch --exit-code > repo.patch || echo "patch_created=true" >> $GITHUB_OUTPUT 34 | working-directory: ./ 35 | - name: Upload patch 36 | if: steps.create_patch.outputs.patch_created 37 | uses: actions/upload-artifact@v4.4.0 38 | with: 39 | name: repo.patch 40 | path: repo.patch 41 | overwrite: true 42 | pr: 43 | name: Create Pull Request 44 | needs: upgrade 45 | runs-on: ubuntu-latest 46 | permissions: 47 | contents: read 48 | if: ${{ needs.upgrade.outputs.patch_created }} 49 | steps: 50 | - name: Checkout 51 | uses: actions/checkout@v4 52 | with: 53 | ref: master 54 | - name: Download patch 55 | uses: actions/download-artifact@v4 56 | with: 57 | name: repo.patch 58 | path: ${{ runner.temp }} 59 | - name: Apply patch 60 | run: '[ -s ${{ runner.temp }}/repo.patch ] && git apply ${{ runner.temp }}/repo.patch || echo "Empty patch. Skipping."' 61 | - name: Set git identity 62 | run: |- 63 | git config user.name "github-actions" 64 | git config user.email "github-actions@github.com" 65 | - name: Create Pull Request 66 | id: create-pr 67 | uses: peter-evans/create-pull-request@v6 68 | with: 69 | token: ${{ secrets.PROJEN_GITHUB_TOKEN }} 70 | commit-message: |- 71 | chore(deps): upgrade dependencies 72 | 73 | Upgrades project dependencies. See details in [workflow run]. 74 | 75 | [Workflow Run]: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} 76 | 77 | ------ 78 | 79 | *Automatically created by projen via the "upgrade-master" workflow* 80 | branch: github-actions/upgrade-master 81 | title: "chore(deps): upgrade dependencies" 82 | body: |- 83 | Upgrades project dependencies. See details in [workflow run]. 84 | 85 | [Workflow Run]: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} 86 | 87 | ------ 88 | 89 | *Automatically created by projen via the "upgrade-master" workflow* 90 | author: github-actions 91 | committer: github-actions 92 | signoff: true 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | !/.gitattributes 3 | !/.projen/tasks.json 4 | !/.projen/deps.json 5 | !/.projen/files.json 6 | !/.github/workflows/pull-request-lint.yml 7 | !/package.json 8 | !/LICENSE 9 | !/.npmignore 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | lerna-debug.log* 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | lib-cov 22 | coverage 23 | *.lcov 24 | .nyc_output 25 | build/Release 26 | node_modules/ 27 | jspm_packages/ 28 | *.tsbuildinfo 29 | .eslintcache 30 | *.tgz 31 | .yarn-integrity 32 | .cache 33 | /test-reports/ 34 | junit.xml 35 | /coverage/ 36 | !/.github/workflows/build.yml 37 | /dist/changelog.md 38 | /dist/version.txt 39 | !/.github/workflows/release.yml 40 | !/.mergify.yml 41 | !/.github/workflows/upgrade-master.yml 42 | !/.github/pull_request_template.md 43 | !/test/ 44 | !/tsconfig.dev.json 45 | !/src/ 46 | /lib 47 | /dist/ 48 | !/.eslintrc.json 49 | .jsii 50 | tsconfig.json 51 | !/API.md 52 | /assets/ 53 | !/src/email-receiver/whitelist-function.ts 54 | !/src/slack-app/provider-function.ts 55 | !/src/slack-events/events-function.ts 56 | !/src/slack-textract/detect-function.ts 57 | !/src/ssl-server-test/analyze-function.ts 58 | !/src/ssl-server-test/extract-grade-function.ts 59 | !/src/toolkit-cleaner/clean-images-function.ts 60 | !/src/toolkit-cleaner/clean-objects-function.ts 61 | !/src/toolkit-cleaner/extract-template-hashes-function.ts 62 | !/src/toolkit-cleaner/get-stack-names-function.ts 63 | !/src/url-shortener/shortener-function.ts 64 | !/src/static-website/origin-request-function.ts 65 | !/src/url-shortener/redirect-function.ts 66 | test/mjml-template/.tmp 67 | test/mjml-template/mjml-template.integ.snapshot/asset.* 68 | test/mjml-template/mjml-template.integ.snapshot/**/asset.* 69 | test/mjml-template/mjml-template.integ.snapshot/cdk.out 70 | test/mjml-template/mjml-template.integ.snapshot/**/cdk.out 71 | test/mjml-template/mjml-template.integ.snapshot/manifest.json 72 | test/mjml-template/mjml-template.integ.snapshot/**/manifest.json 73 | test/mjml-template/mjml-template.integ.snapshot/tree.json 74 | test/mjml-template/mjml-template.integ.snapshot/**/tree.json 75 | test/slack-app/.tmp 76 | test/slack-app/slack-app.integ.snapshot/asset.* 77 | test/slack-app/slack-app.integ.snapshot/**/asset.* 78 | test/slack-app/slack-app.integ.snapshot/cdk.out 79 | test/slack-app/slack-app.integ.snapshot/**/cdk.out 80 | test/slack-app/slack-app.integ.snapshot/manifest.json 81 | test/slack-app/slack-app.integ.snapshot/**/manifest.json 82 | test/slack-app/slack-app.integ.snapshot/tree.json 83 | test/slack-app/slack-app.integ.snapshot/**/tree.json 84 | test/ssl-server-test/.tmp 85 | test/ssl-server-test/ssl-server-test.integ.snapshot/asset.* 86 | test/ssl-server-test/ssl-server-test.integ.snapshot/**/asset.* 87 | test/ssl-server-test/ssl-server-test.integ.snapshot/cdk.out 88 | test/ssl-server-test/ssl-server-test.integ.snapshot/**/cdk.out 89 | test/ssl-server-test/ssl-server-test.integ.snapshot/manifest.json 90 | test/ssl-server-test/ssl-server-test.integ.snapshot/**/manifest.json 91 | test/ssl-server-test/ssl-server-test.integ.snapshot/tree.json 92 | test/ssl-server-test/ssl-server-test.integ.snapshot/**/tree.json 93 | test/toolkit-cleaner/.tmp 94 | test/toolkit-cleaner/toolkit-cleaner.integ.snapshot/asset.* 95 | test/toolkit-cleaner/toolkit-cleaner.integ.snapshot/**/asset.* 96 | test/toolkit-cleaner/toolkit-cleaner.integ.snapshot/cdk.out 97 | test/toolkit-cleaner/toolkit-cleaner.integ.snapshot/**/cdk.out 98 | test/toolkit-cleaner/toolkit-cleaner.integ.snapshot/manifest.json 99 | test/toolkit-cleaner/toolkit-cleaner.integ.snapshot/**/manifest.json 100 | test/toolkit-cleaner/toolkit-cleaner.integ.snapshot/tree.json 101 | test/toolkit-cleaner/toolkit-cleaner.integ.snapshot/**/tree.json 102 | test/url-shortener/.tmp 103 | test/url-shortener/url-shortener.integ.snapshot/asset.* 104 | test/url-shortener/url-shortener.integ.snapshot/**/asset.* 105 | test/url-shortener/url-shortener.integ.snapshot/cdk.out 106 | test/url-shortener/url-shortener.integ.snapshot/**/cdk.out 107 | test/url-shortener/url-shortener.integ.snapshot/manifest.json 108 | test/url-shortener/url-shortener.integ.snapshot/**/manifest.json 109 | test/url-shortener/url-shortener.integ.snapshot/tree.json 110 | test/url-shortener/url-shortener.integ.snapshot/**/tree.json 111 | !/.projenrc.js 112 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | queue_rules: 4 | - name: default 5 | update_method: merge 6 | conditions: 7 | - "#approved-reviews-by>=1" 8 | - -label~=(do-not-merge) 9 | - status-success=build 10 | - status-success=package-js 11 | - status-success=package-java 12 | - status-success=package-python 13 | - status-success=package-go 14 | merge_method: squash 15 | commit_message_template: |- 16 | {{ title }} (#{{ number }}) 17 | 18 | {{ body }} 19 | pull_request_rules: 20 | - name: Automatic merge on approval and successful build 21 | actions: 22 | delete_head_branch: {} 23 | queue: 24 | name: default 25 | conditions: 26 | - "#approved-reviews-by>=1" 27 | - -label~=(do-not-merge) 28 | - status-success=build 29 | - status-success=package-js 30 | - status-success=package-java 31 | - status-success=package-python 32 | - status-success=package-go 33 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | /.projen/ 3 | /test-reports/ 4 | junit.xml 5 | /coverage/ 6 | permissions-backup.acl 7 | /dist/changelog.md 8 | /dist/version.txt 9 | /.mergify.yml 10 | /test/ 11 | /tsconfig.dev.json 12 | /src/ 13 | !/lib/ 14 | !/lib/**/*.js 15 | !/lib/**/*.d.ts 16 | dist 17 | /tsconfig.json 18 | /.github/ 19 | /.vscode/ 20 | /.idea/ 21 | /.projenrc.js 22 | tsconfig.tsbuildinfo 23 | /.eslintrc.json 24 | !.jsii 25 | !/assets/ 26 | test/mjml-template/.tmp 27 | test/mjml-template/mjml-template.integ.snapshot 28 | test/slack-app/.tmp 29 | test/slack-app/slack-app.integ.snapshot 30 | test/ssl-server-test/.tmp 31 | test/ssl-server-test/ssl-server-test.integ.snapshot 32 | test/toolkit-cleaner/.tmp 33 | test/toolkit-cleaner/toolkit-cleaner.integ.snapshot 34 | test/url-shortener/.tmp 35 | test/url-shortener/url-shortener.integ.snapshot 36 | /.gitattributes 37 | -------------------------------------------------------------------------------- /.projen/deps.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": [ 3 | { 4 | "name": "@aws-sdk/client-cloudformation", 5 | "type": "build" 6 | }, 7 | { 8 | "name": "@aws-sdk/client-dynamodb", 9 | "type": "build" 10 | }, 11 | { 12 | "name": "@aws-sdk/client-ecr", 13 | "type": "build" 14 | }, 15 | { 16 | "name": "@aws-sdk/client-ecs", 17 | "type": "build" 18 | }, 19 | { 20 | "name": "@aws-sdk/client-eventbridge", 21 | "type": "build" 22 | }, 23 | { 24 | "name": "@aws-sdk/client-iam", 25 | "type": "build" 26 | }, 27 | { 28 | "name": "@aws-sdk/client-s3", 29 | "type": "build" 30 | }, 31 | { 32 | "name": "@aws-sdk/client-secrets-manager", 33 | "type": "build" 34 | }, 35 | { 36 | "name": "@aws-sdk/client-sfn", 37 | "type": "build" 38 | }, 39 | { 40 | "name": "@aws-sdk/client-textract", 41 | "type": "build" 42 | }, 43 | { 44 | "name": "@aws-sdk/lib-dynamodb", 45 | "type": "build" 46 | }, 47 | { 48 | "name": "@stylistic/eslint-plugin", 49 | "version": "^2", 50 | "type": "build" 51 | }, 52 | { 53 | "name": "@types/aws-lambda", 54 | "type": "build" 55 | }, 56 | { 57 | "name": "@types/jest", 58 | "type": "build" 59 | }, 60 | { 61 | "name": "@types/mjml", 62 | "type": "build" 63 | }, 64 | { 65 | "name": "@types/node", 66 | "type": "build" 67 | }, 68 | { 69 | "name": "@types/tsscmp", 70 | "type": "build" 71 | }, 72 | { 73 | "name": "@typescript-eslint/eslint-plugin", 74 | "version": "^8", 75 | "type": "build" 76 | }, 77 | { 78 | "name": "@typescript-eslint/parser", 79 | "version": "^8", 80 | "type": "build" 81 | }, 82 | { 83 | "name": "aws-cdk", 84 | "version": "^2", 85 | "type": "build" 86 | }, 87 | { 88 | "name": "aws-sdk-client-mock", 89 | "type": "build" 90 | }, 91 | { 92 | "name": "aws-sdk-client-mock-jest", 93 | "type": "build" 94 | }, 95 | { 96 | "name": "commit-and-tag-version", 97 | "version": "^12", 98 | "type": "build" 99 | }, 100 | { 101 | "name": "esbuild", 102 | "type": "build" 103 | }, 104 | { 105 | "name": "eslint-import-resolver-typescript", 106 | "type": "build" 107 | }, 108 | { 109 | "name": "eslint-plugin-import", 110 | "type": "build" 111 | }, 112 | { 113 | "name": "eslint", 114 | "version": "^9", 115 | "type": "build" 116 | }, 117 | { 118 | "name": "jest", 119 | "type": "build" 120 | }, 121 | { 122 | "name": "jest-junit", 123 | "version": "^16", 124 | "type": "build" 125 | }, 126 | { 127 | "name": "jsii-diff", 128 | "type": "build" 129 | }, 130 | { 131 | "name": "jsii-docgen", 132 | "version": "^10.5.0", 133 | "type": "build" 134 | }, 135 | { 136 | "name": "jsii-pacmak", 137 | "type": "build" 138 | }, 139 | { 140 | "name": "jsii-rosetta", 141 | "version": "5.x", 142 | "type": "build" 143 | }, 144 | { 145 | "name": "jsii", 146 | "version": "5.x", 147 | "type": "build" 148 | }, 149 | { 150 | "name": "nock", 151 | "type": "build" 152 | }, 153 | { 154 | "name": "projen", 155 | "type": "build" 156 | }, 157 | { 158 | "name": "ts-jest", 159 | "type": "build" 160 | }, 161 | { 162 | "name": "ts-node", 163 | "type": "build" 164 | }, 165 | { 166 | "name": "typescript", 167 | "type": "build" 168 | }, 169 | { 170 | "name": "@slack/web-api", 171 | "type": "bundled" 172 | }, 173 | { 174 | "name": "got", 175 | "type": "bundled" 176 | }, 177 | { 178 | "name": "mjml", 179 | "type": "bundled" 180 | }, 181 | { 182 | "name": "aws-cdk-lib", 183 | "version": "^2.133.0", 184 | "type": "peer" 185 | }, 186 | { 187 | "name": "constructs", 188 | "version": "^10.0.5", 189 | "type": "peer" 190 | } 191 | ], 192 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 193 | } 194 | -------------------------------------------------------------------------------- /.projen/files.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | ".eslintrc.json", 4 | ".gitattributes", 5 | ".github/pull_request_template.md", 6 | ".github/workflows/build.yml", 7 | ".github/workflows/pull-request-lint.yml", 8 | ".github/workflows/release.yml", 9 | ".github/workflows/upgrade-master.yml", 10 | ".gitignore", 11 | ".mergify.yml", 12 | ".projen/deps.json", 13 | ".projen/files.json", 14 | ".projen/tasks.json", 15 | "LICENSE", 16 | "src/email-receiver/whitelist-function.ts", 17 | "src/slack-app/provider-function.ts", 18 | "src/slack-events/events-function.ts", 19 | "src/slack-textract/detect-function.ts", 20 | "src/ssl-server-test/analyze-function.ts", 21 | "src/ssl-server-test/extract-grade-function.ts", 22 | "src/static-website/origin-request-function.ts", 23 | "src/toolkit-cleaner/clean-images-function.ts", 24 | "src/toolkit-cleaner/clean-objects-function.ts", 25 | "src/toolkit-cleaner/extract-template-hashes-function.ts", 26 | "src/toolkit-cleaner/get-stack-names-function.ts", 27 | "src/url-shortener/redirect-function.ts", 28 | "src/url-shortener/shortener-function.ts", 29 | "tsconfig.dev.json" 30 | ], 31 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 32 | } 33 | -------------------------------------------------------------------------------- /.projenrc.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { awscdk } = require('projen'); 3 | 4 | const project = new awscdk.AwsCdkConstructLibrary({ 5 | authorAddress: 'jonathan.goldwasser@gmail.com', 6 | authorName: 'Jonathan Goldwasser', 7 | description: 'High-level constructs for AWS CDK', 8 | jsiiVersion: '5.x', 9 | cdkVersion: '2.133.0', 10 | name: 'cloudstructs', 11 | repository: 'https://github.com/jogold/cloudstructs.git', 12 | peerDeps: [], 13 | bundledDeps: [ 14 | 'got', 15 | '@slack/web-api', 16 | 'mjml', 17 | ], 18 | devDeps: [ 19 | '@aws-sdk/client-cloudformation', 20 | '@aws-sdk/client-dynamodb', 21 | '@aws-sdk/client-ecr', 22 | '@aws-sdk/client-ecs', 23 | '@aws-sdk/client-eventbridge', 24 | '@aws-sdk/client-iam', 25 | '@aws-sdk/client-s3', 26 | '@aws-sdk/client-secrets-manager', 27 | '@aws-sdk/client-sfn', 28 | '@aws-sdk/client-textract', 29 | '@aws-sdk/lib-dynamodb', 30 | '@types/aws-lambda', 31 | '@types/mjml', 32 | '@types/tsscmp', 33 | 'aws-sdk-client-mock', 34 | 'aws-sdk-client-mock-jest', 35 | 'nock', 36 | ], 37 | defaultReleaseBranch: 'master', 38 | releaseToNpm: true, 39 | publishToPypi: { 40 | distName: 'cloudstructs', 41 | module: 'cloudstructs', 42 | }, 43 | publishToGo: { 44 | moduleName: 'github.com/jogold/cloudstructs-go', 45 | }, 46 | publishToMaven: { 47 | mavenGroupId: 'io.github.jogold', 48 | javaPackage: 'io.github.jogold.cloudstructs', 49 | mavenArtifactId: 'cloudstructs', 50 | mavenEndpoint: 'https://s01.oss.sonatype.org', 51 | }, 52 | lambdaOptions: { 53 | runtime: awscdk.LambdaRuntime.NODEJS_22_X, 54 | }, 55 | }); 56 | 57 | // Update integ test snapshots after upgrade 58 | project.upgradeWorkflow?.postUpgradeTask.spawn(project.tasks.tryFind('bundle')); 59 | project.upgradeWorkflow?.postUpgradeTask.spawn(project.tasks.tryFind('integ:snapshot-all')); 60 | 61 | // Add "exports" 62 | const packageExports = { 63 | '.': './lib/index.js', 64 | './package.json': './package.json', 65 | './.jsii': './.jsii', 66 | }; 67 | for (const dirent of fs.readdirSync('./src', { withFileTypes: true })) { 68 | if (dirent.isDirectory()) { 69 | const construct = dirent.name; 70 | // TODO: remove "lib" when TypeScript supports "exports" 71 | packageExports[`./lib/${construct}`] = `./lib/${construct}/index.js`; 72 | } 73 | } 74 | project.tryFindObjectFile('package.json').addOverride('exports', packageExports); 75 | 76 | project.synth(); 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cloudstructs 2 | 3 | High-level constructs for AWS CDK 4 | 5 | ## Installation 6 | 7 | `npm install cloudstructs` or `yarn add cloudstructs` 8 | 9 | Version >= 0.2.0 requires AWS CDK v2. 10 | 11 | ## Constructs 12 | 13 | * [`CodeCommitMirror`](src/codecommit-mirror) Mirror a repository to AWS CodeCommit on schedule 14 | 15 | * [`EcsServiceRoller`](src/ecs-service-roller) Roll your ECS service tasks on schedule or with 16 | a rule 17 | 18 | * [`EmailReceiver`](src/email-receiver) Receive emails through SES, save them to S3 19 | and invoke a Lambda function 20 | 21 | * [`MjmlTemplate`](src/mjml-template) SES email template from [MJML](https://mjml.io/) 22 | 23 | * [`SlackApp`](src/slack-app) Deploy Slack apps from manifests 24 | 25 | * [`SlackEvents`](src/slack-events) Send Slack events to Amazon EventBridge 26 | 27 | * [`SlackTextract`](src/slack-textract) Extract text from images posted to Slack 28 | using Amazon Textract. The extracted text is posted in a thread under the image 29 | and gets indexed! 30 | 31 | * [`SslServerTest`](src/ssl-server-test) Test a server/host for SSL/TLS on schedule and 32 | get notified when the overall rating is not satisfactory. [Powered by Qualys SSL Labs](https://www.ssllabs.com/). 33 | 34 | * [`StateMachineCustomResourceProvider`](src/state-machine-cr-provider) Implement custom 35 | resources with AWS Step Functions state machines 36 | 37 | * [`StaticWebsite`](src/static-website) A CloudFront static website hosted on S3 with 38 | HTTPS redirect, SPA redirect, HTTP security headers and backend configuration saved 39 | to the bucket. 40 | 41 | * [`ToolkitCleaner`](src/toolkit-cleaner) Clean unused S3 and ECR assets from your CDK 42 | Toolkit. 43 | 44 | * [`UrlShortener`](src/url-shortener) Deploy an URL shortener API 45 | -------------------------------------------------------------------------------- /assets/codecommit-mirror/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.12 2 | 3 | RUN apk add --no-cache \ 4 | git \ 5 | python3 6 | 7 | RUN python3 -m ensurepip && \ 8 | rm -r /usr/lib/python*/ensurepip && \ 9 | pip3 install --upgrade pip awscli git-remote-codecommit && \ 10 | rm -r /root/.cache 11 | 12 | COPY mirror.sh . 13 | 14 | CMD ["./mirror.sh"] 15 | -------------------------------------------------------------------------------- /assets/codecommit-mirror/docker/mirror.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo Mirroring $NAME 4 | 5 | echo Cloning source... 6 | git clone --mirror $SOURCE source 7 | 8 | cd source 9 | 10 | git remote add destination $DESTINATION 11 | 12 | echo Pushing to destination... 13 | git push destination --mirror 14 | 15 | echo Done! 16 | -------------------------------------------------------------------------------- /assets/toolkit-cleaner/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | 3 | ADD dummy.txt / 4 | -------------------------------------------------------------------------------- /assets/toolkit-cleaner/docker/dummy.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jogold/cloudstructs/913c49dd28216423e77b59cd58272b6a131b98d9/assets/toolkit-cleaner/docker/dummy.txt -------------------------------------------------------------------------------- /src/codecommit-mirror/README.md: -------------------------------------------------------------------------------- 1 | # CodeCommitMirror 2 | 3 | Mirror a repository to AWS CodeCommit on schedule. 4 | 5 | ## Usage 6 | 7 | Define a `CodeCommitMirror`: 8 | 9 | ```ts 10 | import * as events from 'aws-cdk-lib/aws-events'; 11 | import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; 12 | import * as ecs from 'aws-cdk-lib/aws-ecs'; 13 | import { Stack, StackProps } from 'aws-cdk-lib'; 14 | import * as cloudstructs from 'cloudstructs'; 15 | import { Construct } from 'constructs'; 16 | 17 | export class MyStack extends Stack { 18 | constructor(scope: Construct, id: string, props?: StackProps) { 19 | super(scope, id, props); 20 | 21 | // code that defines or imports a cluster where the Fargate tasks will run the mirroring 22 | // operations 23 | 24 | // Mirror a public GitHub repository everyday at midnight 25 | new cloudstructs.CodeCommitMirror(this, 'Public', { 26 | cluster: myCluster, 27 | repository: cloudstructs.CodeCommitMirrorSourceRepository.gitHub('jogold', 'cloudstructs') 28 | }); 29 | 30 | // Mirror a private GitHub repository every 6 hours. 31 | // The `private-repo-url` secret contains `https://TOKEN@github.com/owner/my-private-github-repo` 32 | const urlSecret = secretsmanager.Secret.fromSecretNameV2(this, 'Secret', 'private-repo-url'); 33 | new cloudstructs.CodeCommitMirror(this, 'Private', { 34 | cluster: myCluster, 35 | repository: cloudstructs.CodeCommitMirrorSourceRepository.private('private', ecs.Secret.fromSecretsManager(urlSecret)), 36 | schedule: events.Schedule.rate(cdk.Duration.hours(6)), 37 | }); 38 | } 39 | } 40 | ``` 41 | -------------------------------------------------------------------------------- /src/codecommit-mirror/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as codecommit from 'aws-cdk-lib/aws-codecommit'; 3 | import * as ec2 from 'aws-cdk-lib/aws-ec2'; 4 | import * as ecs from 'aws-cdk-lib/aws-ecs'; 5 | import * as events from 'aws-cdk-lib/aws-events'; 6 | import * as targets from 'aws-cdk-lib/aws-events-targets'; 7 | import * as iam from 'aws-cdk-lib/aws-iam'; 8 | import * as logs from 'aws-cdk-lib/aws-logs'; 9 | import { Construct } from 'constructs'; 10 | 11 | /** 12 | * Properties for a CodeCommitMirror 13 | */ 14 | export interface CodeCommitMirrorProps { 15 | /** 16 | * The source repository 17 | */ 18 | readonly repository: CodeCommitMirrorSourceRepository; 19 | 20 | /** 21 | * The ECS cluster where to run the mirroring operation 22 | */ 23 | readonly cluster: ecs.ICluster; 24 | 25 | /** 26 | * The schedule for the mirroring operation 27 | * 28 | * @default - everyday at midnight 29 | */ 30 | readonly schedule?: events.Schedule; 31 | 32 | /** 33 | * Where to run the mirroring Fargate tasks 34 | * 35 | * @default - public subnets 36 | */ 37 | readonly subnetSelection?: ec2.SubnetSelection; 38 | } 39 | 40 | /** 41 | * A source repository for AWS CodeCommit mirroring 42 | */ 43 | export abstract class CodeCommitMirrorSourceRepository { 44 | /** 45 | * Public GitHub repository 46 | */ 47 | public static gitHub(owner: string, name: string): CodeCommitMirrorSourceRepository { 48 | return { 49 | name, 50 | plainTextUrl: `https://github.com/${owner}/${name}`, 51 | }; 52 | } 53 | 54 | /** 55 | * Private repository with HTTPS clone URL stored in a AWS Secrets Manager secret or 56 | * a AWS Systems Manager secure string parameter. 57 | * 58 | * @param name the repository name 59 | * @param url the secret containing the HTTPS clone URL 60 | */ 61 | public static private(name: string, url: ecs.Secret): CodeCommitMirrorSourceRepository { 62 | return { 63 | name, 64 | secretUrl: url, 65 | }; 66 | } 67 | 68 | /** 69 | * The name of the repository 70 | */ 71 | public abstract readonly name: string; 72 | 73 | /** The HTTPS clone URL in plain text, used for a public repository */ 74 | public abstract readonly plainTextUrl?: string; 75 | 76 | /** 77 | * The HTTPS clone URL if the repository is private. 78 | * 79 | * The secret should contain the username and/or token. 80 | * 81 | * @example 82 | * `https://TOKEN@github.com/owner/name` 83 | * `https://USERNAME:TOKEN@bitbucket.org/owner/name.git` 84 | */ 85 | public abstract readonly secretUrl?: ecs.Secret; 86 | } 87 | 88 | /** 89 | * Mirror a repository to AWS CodeCommit on schedule 90 | */ 91 | export class CodeCommitMirror extends Construct { 92 | constructor(scope: Construct, id: string, props: CodeCommitMirrorProps) { 93 | super(scope, id); 94 | 95 | const destination = new codecommit.Repository(this, 'Repository', { 96 | repositoryName: props.repository.name, 97 | description: `Mirror of ${props.repository.name}`, 98 | }); 99 | 100 | const taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDefinition'); 101 | 102 | taskDefinition.addContainer('Container', { 103 | image: ecs.ContainerImage.fromAsset(path.join(__dirname, '..', '..', 'assets', 'codecommit-mirror', 'docker')), 104 | logging: new ecs.AwsLogDriver({ 105 | streamPrefix: props.repository.name, 106 | logRetention: logs.RetentionDays.TWO_MONTHS, 107 | }), 108 | environment: { 109 | NAME: props.repository.name, 110 | DESTINATION: destination.repositoryCloneUrlGrc, 111 | ...props.repository.plainTextUrl 112 | ? { SOURCE: props.repository.plainTextUrl } 113 | : {}, 114 | }, 115 | secrets: props.repository.secretUrl 116 | ? { SOURCE: props.repository.secretUrl } 117 | : undefined, 118 | }); 119 | 120 | taskDefinition.addToTaskRolePolicy(new iam.PolicyStatement({ 121 | actions: ['codecommit:GitPush'], 122 | resources: [destination.repositoryArn], 123 | })); 124 | 125 | const rule = new events.Rule(this, 'Rule', { 126 | schedule: props.schedule ?? events.Schedule.cron({ 127 | minute: '0', 128 | hour: '0', 129 | }), 130 | }); 131 | 132 | rule.addTarget(new targets.EcsTask({ 133 | cluster: props.cluster, 134 | taskDefinition, 135 | subnetSelection: props.subnetSelection ?? { subnetType: ec2.SubnetType.PUBLIC }, 136 | })); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/dmarc/README.md: -------------------------------------------------------------------------------- 1 | # DmarcReporter 2 | 3 | This construct allows you to configure a Route 53 DMARC record and set up an email receiver for DMARC reports. It helps you to monitor and enforce your DMARC policy, ensuring that your domain is protected against email spoofing and phishing attacks. 4 | 5 | ## Usage 6 | 7 | Define a `DmarcReporter`: 8 | 9 | ```ts 10 | import { Stack, StackProps } from "aws-cdk-lib"; 11 | import { 12 | DmarcReporter, 13 | DmarcReporterProps, 14 | DmarcPolicy, 15 | DmarcAlignment, 16 | } from "cloudstructs"; 17 | import { Construct } from "constructs"; 18 | 19 | export class MyStack extends Stack { 20 | constructor(scope: Construct, id: string, props?: StackProps) { 21 | super(scope, id, props); 22 | 23 | const dmarcReporterProps: DmarcReporterProps = { 24 | hostedZone: myHostedZone, 25 | 26 | // optional, defaults to dmarc-reports@ 27 | emailAddress: "dmarc-reports@example.com", 28 | 29 | // optional, other email addresses that receive dmarc reports 30 | additionalEmailAddresses: ["additional@example.com"], 31 | 32 | // you could use DmarcPolicy.NONE to just receive dmarc reports 33 | // to help you investigate DMARC conformance 34 | dmarcPolicy: DmarcPolicy.QUARANTINE, 35 | 36 | // optional, inherited from dmarcPolicy 37 | dmarcSubdomainPolicy: DmarcPolicy.NONE, 38 | 39 | // optional, defaults to 100 40 | dmarcPercentage: 100, 41 | 42 | // optional, defaults to DmarcAlignment.RELAXED 43 | dmarcDkimAlignment: DmarcAlignment.RELAXED, 44 | 45 | // optional, defaults to DmarcAlignment.RELAXED 46 | dmarcSpfAlignment: DmarcAlignment.STRICT, 47 | 48 | // lambda function that processes dmarc reports 49 | // receives a [`AWSLambda.SESMessage`](https://www.npmjs.com/package/@types/aws-lambda) 50 | function: myFn, 51 | 52 | receiptRuleSet: myRuleSet, 53 | }; 54 | 55 | new DmarcReporter(this, "DmarcReporter", dmarcReporterProps); 56 | } 57 | } 58 | ``` 59 | 60 | Your Lambda function conveniently receives a [`AWSLambda.SESMessage`](https://www.npmjs.com/package/@types/aws-lambda) 61 | event: 62 | 63 | ```ts 64 | import { S3 } from "aws-sdk"; 65 | import { SESMessage } from "cloudstructs"; 66 | 67 | const s3 = new S3({ apiVersion: "2006-03-01" }); 68 | 69 | export async function handler(event: AWSLambda.SESMessage): Promise { 70 | // Download email 71 | const rawEmail = await s3 72 | .getObject({ 73 | Bucket: event.receipt.action.bucketName, 74 | Key: event.receipt.action.objectKey, 75 | }) 76 | .promise(); 77 | 78 | // ... do something with email ... 79 | } 80 | ``` 81 | -------------------------------------------------------------------------------- /src/dmarc/index.ts: -------------------------------------------------------------------------------- 1 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 2 | import * as route53 from 'aws-cdk-lib/aws-route53'; 3 | import * as ses from 'aws-cdk-lib/aws-ses'; 4 | 5 | import { Construct } from 'constructs'; 6 | import { EmailReceiver } from '../email-receiver/receiver'; 7 | 8 | /** 9 | * The DMARC policy to apply to messages that fail DMARC compliance. 10 | */ 11 | export enum DmarcPolicy { 12 | /** 13 | * Do not apply any special handling to messages that fail DMARC compliance. 14 | */ 15 | NONE = 'none', 16 | /** 17 | * Quarantine messages that fail DMARC compliance. (usually by sending them to spam) 18 | */ 19 | QUARANTINE = 'quarantine', 20 | /** 21 | * Reject messages that fail DMARC compliance. (usually by rejecting them outright) 22 | */ 23 | REJECT = 'reject', 24 | } 25 | 26 | /** 27 | * The DMARC alignment mode. 28 | */ 29 | export enum DmarcAlignment { 30 | /** 31 | * Relaxed alignment mode. 32 | */ 33 | RELAXED = 'relaxed', 34 | /** 35 | * Strict alignment mode. 36 | */ 37 | STRICT = 'strict', 38 | } 39 | 40 | /** 41 | * Properties for a DmarcReporter 42 | */ 43 | export interface DmarcReporterProps { 44 | /** 45 | * The Route 53 hosted zone to create the DMARC record in. 46 | */ 47 | readonly hostedZone: route53.IHostedZone; 48 | /** 49 | * The email address to send DMARC reports to. 50 | * This email address must be verified in SES. 51 | * @default dmarc-reports@${hostedZone.zoneName} 52 | */ 53 | readonly emailAddress?: string; 54 | 55 | /** 56 | * Additional email addresses to send DMARC reports to. 57 | */ 58 | readonly additionalEmailAddresses?: string[]; 59 | 60 | /** 61 | * The DMARC policy to apply to messages that fail DMARC compliance. 62 | * This can be one of the following values: 63 | * - none: Do not apply any special handling to messages that fail DMARC compliance. 64 | * - quarantine: Quarantine messages that fail DMARC compliance. 65 | * - reject: Reject messages that fail DMARC compliance. 66 | */ 67 | readonly dmarcPolicy: DmarcPolicy; 68 | 69 | /** 70 | * The DMARC policy to apply to messages that fail DMARC compliance for subdomains. 71 | * This can be one of the following values: 72 | * - none: Do not apply any special handling to messages that fail DMARC compliance. 73 | * - quarantine: Quarantine messages that fail DMARC compliance. 74 | * - reject: Reject messages that fail DMARC compliance. 75 | * @default inherited from dmarcPolicy 76 | */ 77 | readonly dmarcSubdomainPolicy?: DmarcPolicy; 78 | 79 | /** 80 | * The percentage of messages that should be checked for DMARC compliance. 81 | * This is a value between 0 and 100. 82 | * @default 100 83 | */ 84 | readonly dmarcPercentage?: number; 85 | 86 | /** 87 | * The alignment mode to use for DKIM signatures. 88 | * This can be one of the following values: 89 | * - relaxed: Use relaxed alignment mode. 90 | * - strict: Use strict alignment mode. 91 | * 92 | * @default relaxed 93 | */ 94 | readonly dmarcDkimAlignment?: DmarcAlignment; 95 | 96 | /** 97 | * The alignment mode to use for SPF signatures. 98 | * This can be one of the following values: 99 | * - relaxed: Use relaxed alignment mode. 100 | * - strict: Use strict alignment mode. 101 | * 102 | * @default relaxed 103 | */ 104 | readonly dmarcSpfAlignment?: DmarcAlignment; 105 | 106 | /** 107 | * A Lambda function to invoke after the message is saved to S3. The Lambda 108 | * function will be invoked with a SESMessage as event. 109 | */ 110 | readonly function: lambda.IFunction; 111 | 112 | /** 113 | * An existing rule after which the new rule will be placed in the rule set. 114 | * 115 | * @default - The new rule is inserted at the beginning of the rule list. 116 | */ 117 | readonly afterRule?: ses.IReceiptRule; 118 | 119 | /** 120 | * The SES receipt rule set where a receipt rule will be added 121 | */ 122 | readonly receiptRuleSet: ses.IReceiptRuleSet; 123 | } 124 | 125 | /** 126 | * Creates a DMARC record in Route 53 and invokes a Lambda function to process incoming reports. 127 | */ 128 | export class DmarcReporter extends Construct { 129 | constructor(scope: Construct, id: string, props: DmarcReporterProps) { 130 | super(scope, id); 131 | 132 | const emailAddress = 133 | props.emailAddress ?? `dmarc-reports@${props.hostedZone.zoneName}`; 134 | 135 | new EmailReceiver(this, 'EmailReceiver', { 136 | recipients: [emailAddress], 137 | function: props.function, 138 | afterRule: props.afterRule, 139 | receiptRuleSet: props.receiptRuleSet, 140 | }); 141 | 142 | const dmarcRecordValue = [ 143 | 'v=DMARC1', 144 | `p=${props.dmarcPolicy}`, 145 | `rua=${[emailAddress, ...(props.additionalEmailAddresses ?? [])] 146 | .map((address) => `mailto:${address}`) 147 | .join(',')}`, 148 | ]; 149 | 150 | if (props.dmarcSubdomainPolicy) { 151 | dmarcRecordValue.push(`sp=${props.dmarcSubdomainPolicy}`); 152 | } 153 | if (props.dmarcPercentage) { 154 | dmarcRecordValue.push(`pct=${props.dmarcPercentage}`); 155 | } 156 | if (props.dmarcDkimAlignment) { 157 | dmarcRecordValue.push( 158 | `adkim=${ 159 | props.dmarcDkimAlignment === DmarcAlignment.RELAXED ? 'r' : 's' 160 | }`, 161 | ); 162 | } 163 | if (props.dmarcSpfAlignment) { 164 | dmarcRecordValue.push( 165 | `aspf=${props.dmarcSpfAlignment === DmarcAlignment.RELAXED ? 'r' : 's'}`, 166 | ); 167 | } 168 | 169 | // Create Route 53 DMARC Record 170 | new route53.TxtRecord(this, 'DmarcRecord', { 171 | zone: props.hostedZone, 172 | recordName: `_dmarc.${props.hostedZone.zoneName}`, 173 | values: [dmarcRecordValue.join('; ')], 174 | }); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/ecs-service-roller/README.md: -------------------------------------------------------------------------------- 1 | # EcsServiceRoller 2 | 3 | Roll your ECS service tasks on schedule or with a rule 4 | 5 | ## Usage 6 | 7 | Define a `EcsServiceRoller`: 8 | 9 | ```ts 10 | import { Stack, StackProps } from 'aws-cdk-lib'; 11 | import * as cloudstructs from 'cloudstructs'; 12 | import { Construct } from 'constructs'; 13 | 14 | export class MyStack extends Stack { 15 | constructor(scope: Construct, id: string, props?: StackProps) { 16 | super(scope, id, props); 17 | 18 | // code that defines or imports a cluster and a service 19 | 20 | // Roll tasks of myFirstService everyday at midnight 21 | new cloudstructs.EcsServiceRoller(this, 'MyFirstRoller', { 22 | cluster: myCluster, 23 | service: myFirstService, 24 | }); 25 | 26 | // Roll tasks of mySecondService every 5 hours 27 | new cloudstructs.EcsServiceRoller(this, 'MySecondRoller', { 28 | cluster: myCluster, 29 | service: mySecondService, 30 | trigger: cloudstructs.RollTrigger.fromSchedule(events.Schedule.rate(cdk.Duration.hours(5))), 31 | }); 32 | 33 | // Roll tasks of myThirdService with a rule 34 | new cloudstructs.EcsServiceRoller(this, 'MyThirdRoller', { 35 | cluster: myCluster, 36 | service: myThirdService, 37 | trigger: cloudstructs.RollTrigger.fromRule(rule), 38 | }); 39 | } 40 | } 41 | ``` 42 | -------------------------------------------------------------------------------- /src/ecs-service-roller/index.ts: -------------------------------------------------------------------------------- 1 | import type { UpdateServiceCommandInput } from '@aws-sdk/client-ecs'; 2 | import { Stack } from 'aws-cdk-lib'; 3 | import * as ecs from 'aws-cdk-lib/aws-ecs'; 4 | import * as events from 'aws-cdk-lib/aws-events'; 5 | import * as targets from 'aws-cdk-lib/aws-events-targets'; 6 | import * as iam from 'aws-cdk-lib/aws-iam'; 7 | import { Construct } from 'constructs'; 8 | 9 | /** 10 | * Properties for a EcsServiceRoller 11 | */ 12 | export interface EcsServiceRollerProps { 13 | /** 14 | * The ECS cluster where the services run 15 | */ 16 | readonly cluster: ecs.ICluster; 17 | 18 | /** 19 | * The ECS service for which tasks should be rolled 20 | */ 21 | readonly service: ecs.IService; 22 | 23 | /** 24 | * The rule or schedule that should trigger a roll 25 | * 26 | * @default - roll everyday at midnight 27 | */ 28 | readonly trigger?: RollTrigger; 29 | } 30 | 31 | /** 32 | * The rule or schedule that should trigger a roll 33 | */ 34 | export abstract class RollTrigger { 35 | /** 36 | * Schedule that should trigger a roll 37 | */ 38 | public static fromSchedule(schedule: events.Schedule): RollTrigger { 39 | return { schedule }; 40 | } 41 | 42 | /** 43 | * Rule that should trigger a roll 44 | */ 45 | public static fromRule(rule: events.Rule): RollTrigger { 46 | return { rule }; 47 | } 48 | 49 | /** 50 | * Roll schedule 51 | * 52 | * @default - roll everyday at midnight 53 | */ 54 | public abstract readonly schedule?: events.Schedule; 55 | 56 | /** 57 | * Roll rule 58 | * 59 | * @default - roll everyday at midnight 60 | */ 61 | public abstract readonly rule?: events.Rule; 62 | } 63 | 64 | /** 65 | * Roll your ECS service tasks on schedule or with a rule 66 | */ 67 | export class EcsServiceRoller extends Construct { 68 | constructor(scope: Construct, id: string, props: EcsServiceRollerProps) { 69 | super(scope, id); 70 | 71 | const rule = props.trigger?.rule ?? new events.Rule(this, 'Rule', { 72 | schedule: props.trigger?.schedule ?? events.Schedule.cron({ 73 | minute: '0', 74 | hour: '0', 75 | }), 76 | }); 77 | 78 | 79 | const parameters: UpdateServiceCommandInput = { 80 | service: props.service.serviceName, 81 | cluster: props.cluster.clusterName, 82 | forceNewDeployment: true, 83 | }; 84 | rule.addTarget(new targets.AwsApi({ 85 | service: 'ECS', 86 | action: 'updateService', 87 | parameters, 88 | // https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-supported-iam-actions-resources.html 89 | // arn:aws:ecs:::service// 90 | policyStatement: new iam.PolicyStatement({ 91 | actions: ['ecs:UpdateService'], 92 | resources: [Stack.of(this).formatArn({ 93 | service: 'ecs', 94 | resource: 'service', 95 | resourceName: `${props.cluster.clusterName}/${props.service.serviceName}`, 96 | })], 97 | }), 98 | })); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/email-receiver/README.md: -------------------------------------------------------------------------------- 1 | # EmailReceiver 2 | 3 | Receive emails through SES, save them to S3 and invoke a Lambda function 4 | 5 | ## Usage 6 | 7 | Define a `EmailReceiver`: 8 | 9 | ```ts 10 | import { Stack, StackProps } from 'aws-cdk-lib'; 11 | import * as cloudstructs from 'cloudstructs'; 12 | import { Construct } from 'constructs'; 13 | 14 | export class MyStack extends Stack { 15 | constructor(scope: Construct, id: string, props?: StackProps) { 16 | super(scope, id, props); 17 | 18 | // code that defines or imports a Lambda function and receipt rule set 19 | 20 | new EmailReceiver(this, 'EmailReceiver', { 21 | recipients: ['support@cloudstructs.com'], // Process emails sent to this address 22 | sourceWhitelist: '@amazon.com$', // Reject emails that are not from @amazon.com 23 | function: myFn, 24 | receiptRuleSet: myRuleSet, 25 | }); 26 | } 27 | } 28 | ``` 29 | 30 | Your Lambda function receives a [`AWSLambda.SNSMessage`](https://www.npmjs.com/package/@types/aws-lambda) 31 | event: 32 | 33 | ```ts 34 | import { S3 } from 'aws-sdk'; 35 | import { SESMessage } from 'cloudstructs'; 36 | 37 | const s3 = new S3({ apiVersion: '2006-03-01' }); 38 | 39 | export async function handler(event: AWSLambda.SNSEvent): Promise { 40 | const ses = JSON.parse(event.Records[0].Sns.Message) as AWSLambda.SESMessage; 41 | 42 | // Download email 43 | const rawEmail = await s3.getObject({ 44 | Bucket: ses.receipt.action.bucketName, 45 | Key: ses.receipt.action.objectKey, 46 | }).promise(); 47 | 48 | // ... do something with email ... 49 | } 50 | ``` 51 | -------------------------------------------------------------------------------- /src/email-receiver/index.ts: -------------------------------------------------------------------------------- 1 | export * from './receiver'; 2 | -------------------------------------------------------------------------------- /src/email-receiver/receiver.ts: -------------------------------------------------------------------------------- 1 | import { Duration } from 'aws-cdk-lib'; 2 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 3 | import * as logs from 'aws-cdk-lib/aws-logs'; 4 | import * as s3 from 'aws-cdk-lib/aws-s3'; 5 | import * as ses from 'aws-cdk-lib/aws-ses'; 6 | import * as actions from 'aws-cdk-lib/aws-ses-actions'; 7 | import * as sns from 'aws-cdk-lib/aws-sns'; 8 | import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions'; 9 | import { Construct } from 'constructs'; 10 | import { WhitelistFunction } from './whitelist-function'; 11 | 12 | /** 13 | * Properties for an EmailReceiver 14 | */ 15 | export interface EmailReceiverProps { 16 | /** 17 | * The SES receipt rule set where a receipt rule will be added 18 | */ 19 | readonly receiptRuleSet: ses.IReceiptRuleSet; 20 | 21 | /** 22 | * The recipients for which emails should be received 23 | */ 24 | readonly recipients: string[]; 25 | 26 | /** 27 | * A Lambda function to invoke after the message is saved to S3. The Lambda 28 | * function will be invoked with a SESMessage as event. 29 | */ 30 | readonly function?: lambda.IFunction; 31 | 32 | /** 33 | * A regular expression to whitelist source email addresses 34 | * 35 | * @default - no whitelisting of source email addresses 36 | */ 37 | readonly sourceWhitelist?: string; 38 | 39 | /** 40 | * An existing rule after which the new rule will be placed in the rule set. 41 | * 42 | * @default - The new rule is inserted at the beginning of the rule list. 43 | */ 44 | readonly afterRule?: ses.IReceiptRule; 45 | 46 | /** 47 | * Whether the receiver is active. 48 | * 49 | * @default true 50 | */ 51 | readonly enabled?: boolean; 52 | } 53 | 54 | /** 55 | * Receive emails through SES, save them to S3 and invokes a 56 | * Lambda function 57 | */ 58 | export class EmailReceiver extends Construct { 59 | /** 60 | * The S3 bucket where emails are delivered 61 | */ 62 | public readonly bucket: s3.Bucket; 63 | 64 | /** 65 | * The SNS topic that is notified when emails are delivered to S3 66 | */ 67 | public readonly topic: sns.ITopic; 68 | 69 | constructor(scope: Construct, id: string, props: EmailReceiverProps) { 70 | super(scope, id); 71 | 72 | const receiptRule = new ses.ReceiptRule(this, 'ReceiptRule', { 73 | ruleSet: props.receiptRuleSet, 74 | recipients: props.recipients, 75 | after: props.afterRule, 76 | enabled: props.enabled, 77 | }); 78 | 79 | this.bucket = new s3.Bucket(this, 'Bucket', { 80 | blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, 81 | encryption: s3.BucketEncryption.S3_MANAGED, 82 | lifecycleRules: [{ expiration: Duration.days(1) }], 83 | }); 84 | if (props.function) { 85 | this.bucket.grantRead(props.function); // Download email 86 | } 87 | 88 | this.topic = new sns.Topic(this, 'Topic'); 89 | 90 | // Actions 91 | if (props.sourceWhitelist) { 92 | const whitelistHandler = new WhitelistFunction(this, 'whitelist', { 93 | environment: { 94 | SOURCE_WHITELIST: props.sourceWhitelist, 95 | }, 96 | logRetention: logs.RetentionDays.ONE_MONTH, 97 | }); 98 | 99 | receiptRule.addAction(new actions.Lambda({ 100 | function: whitelistHandler, 101 | invocationType: actions.LambdaInvocationType.REQUEST_RESPONSE, 102 | })); 103 | } 104 | 105 | receiptRule.addAction(new actions.S3({ 106 | bucket: this.bucket, 107 | topic: this.topic, 108 | })); 109 | 110 | if (props.function) { 111 | this.topic.addSubscription(new subscriptions.LambdaSubscription(props.function)); // Notify 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/email-receiver/whitelist-function.ts: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | import * as path from 'path'; 3 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 4 | import { Construct } from 'constructs'; 5 | 6 | /** 7 | * Props for WhitelistFunction 8 | */ 9 | export interface WhitelistFunctionProps extends lambda.FunctionOptions { 10 | } 11 | 12 | /** 13 | * An AWS Lambda function which executes src/email-receiver/whitelist. 14 | */ 15 | export class WhitelistFunction extends lambda.Function { 16 | constructor(scope: Construct, id: string, props?: WhitelistFunctionProps) { 17 | super(scope, id, { 18 | description: 'src/email-receiver/whitelist.lambda.ts', 19 | ...props, 20 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS), 21 | handler: 'index.handler', 22 | code: lambda.Code.fromAsset(path.join(__dirname, '../../assets/email-receiver/whitelist.lambda')), 23 | }); 24 | this.addEnvironment('AWS_NODEJS_CONNECTION_REUSE_ENABLED', '1', { removeInEdge: true }); 25 | } 26 | } -------------------------------------------------------------------------------- /src/email-receiver/whitelist.lambda.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | export async function handler(event: AWSLambda.SESEvent): Promise<{ disposition: string }> { 3 | console.log('Event: %j', event); 4 | 5 | const sesNotification = event.Records[0].ses; 6 | 7 | if (!process.env.SOURCE_WHITELIST) { 8 | console.log('Missing SOURCE_WHITELIST'); 9 | return { disposition: 'STOP_RULE' }; 10 | } 11 | 12 | if (!new RegExp(process.env.SOURCE_WHITELIST).test(sesNotification.mail.source)) { 13 | console.log(`${sesNotification.mail.source} does not match /${process.env.SOURCE_WHITELIST}/`); 14 | return { disposition: 'STOP_RULE' }; 15 | } 16 | 17 | return { disposition: 'CONTINUE' }; 18 | } 19 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './slack-events'; 2 | export * from './slack-textract'; 3 | export * from './static-website'; 4 | export * from './state-machine-cr-provider'; 5 | export * from './email-receiver'; 6 | export * from './mjml-template'; 7 | export * from './ecs-service-roller'; 8 | export * from './url-shortener'; 9 | export * from './saml-identity-provider'; 10 | export * from './codecommit-mirror'; 11 | export * from './slack-app'; 12 | export * from './toolkit-cleaner'; 13 | export * from './ssl-server-test'; 14 | export * from './dmarc'; -------------------------------------------------------------------------------- /src/mjml-template/README.md: -------------------------------------------------------------------------------- 1 | # MjmlTemplate 2 | 3 | SES email template from [MJML](https://mjml.io/) 4 | 5 | ## Usage 6 | 7 | Define a `MjmlTemplate`: 8 | 9 | ```ts 10 | import { Stack, StackProps } from 'aws-cdk-lib'; 11 | import * as cloudstructs from 'cloudstructs'; 12 | import { Construct } from 'constructs'; 13 | 14 | export class MyStack extends Stack { 15 | constructor(scope: Construct, id: string, props?: StackProps) { 16 | super(scope, id, props); 17 | 18 | const mjml = ` 19 | 20 | 21 | 22 | 23 | Hello {{name}} 24 | 25 | 26 | 27 | 28 | `; 29 | 30 | new cloudstructs.MjmlTemplate(this, 'Template', { 31 | subject: 'Welcome!', 32 | mjml, 33 | }); 34 | } 35 | } 36 | ``` 37 | 38 | The deployed template can then be used to [send personalized email](https://docs.aws.amazon.com/ses/latest/dg/send-personalized-email-api.html) with the Amazon SES API. -------------------------------------------------------------------------------- /src/mjml-template/index.ts: -------------------------------------------------------------------------------- 1 | import { CfnTemplate } from 'aws-cdk-lib/aws-ses'; 2 | import { Construct } from 'constructs'; 3 | import mjml2html = require('mjml'); // eslint-disable-line @typescript-eslint/no-require-imports 4 | 5 | /** 6 | * Properties for a MjmlTemplate 7 | */ 8 | export interface MjmlTemplateProps { 9 | /** 10 | * The name of the template 11 | * 12 | * @default - a CloudFormation generated name 13 | */ 14 | readonly templateName?: string; 15 | 16 | /** 17 | * The subject line of the email 18 | */ 19 | readonly subject: string; 20 | 21 | /** 22 | * The MJML for the email 23 | */ 24 | readonly mjml: string; 25 | } 26 | 27 | /** 28 | * SES email template from [MJML](https://mjml.io/) 29 | */ 30 | export class MjmlTemplate extends Construct { 31 | /** 32 | * The name of the template 33 | */ 34 | public readonly templateName: string; 35 | 36 | constructor(scope: Construct, id: string, props: MjmlTemplateProps) { 37 | super(scope, id); 38 | 39 | const template = new CfnTemplate(this, 'Resource', { 40 | template: { 41 | templateName: props.templateName, 42 | subjectPart: props.subject, 43 | htmlPart: mjml2html(props.mjml).html, 44 | }, 45 | }); 46 | 47 | this.templateName = template.attrId; 48 | } 49 | } -------------------------------------------------------------------------------- /src/saml-identity-provider/README.md: -------------------------------------------------------------------------------- 1 | # SamlIdentityProvider 2 | 3 | Custom resource to create a SAML identity provider 4 | 5 | ## Usage 6 | 7 | Define a `SamlIdentityProvider`: 8 | 9 | ```ts 10 | import { Stack, StackProps } from 'aws-cdk-lib'; 11 | import * as cloudstructs from 'cloudstructs'; 12 | import { Construct } from 'constructs'; 13 | import * as fs from 'fs'; 14 | 15 | export class MyStack extends Stack { 16 | constructor(scope: Construct, id: string, props?: StackProps) { 17 | super(scope, id, props); 18 | 19 | const metadataDocument = fs.readFileSync('./my-document.xml', 'utf-8'); 20 | 21 | new cloudstructs.SamlIdentityProvider(this, 'IdentityProvider', { metadataDocument }); 22 | } 23 | } 24 | ``` 25 | 26 | The ARN of the identity provider is exposed via the `samlIdentityProviderArn` property. 27 | 28 | Use the `SamlFederatedPrincipal` principal to create a `iam.Role` assumed by the identity 29 | provider: 30 | 31 | ```ts 32 | new iam.Role(this, 'Role', { 33 | assumedBy: new SamlFederatedPrincipal(identityProvider), 34 | }) 35 | ``` 36 | -------------------------------------------------------------------------------- /src/saml-identity-provider/index.ts: -------------------------------------------------------------------------------- 1 | import type { CreateSAMLProviderCommandInput, UpdateSAMLProviderCommandInput, DeleteSAMLProviderCommandInput } from '@aws-sdk/client-iam'; 2 | import { Names, Stack } from 'aws-cdk-lib'; 3 | import * as iam from 'aws-cdk-lib/aws-iam'; 4 | import * as cr from 'aws-cdk-lib/custom-resources'; 5 | import { Construct } from 'constructs'; 6 | 7 | /** 8 | * Properties for a SamlProvider 9 | * 10 | * @deprecated use `SamlProviderProps` from `aws-cdk-lib/aws-iam` 11 | */ 12 | export interface SamlIdentityProviderProps { 13 | /** 14 | * A name for the SAML identity provider 15 | * 16 | * @default - derived for the node's unique id 17 | */ 18 | readonly name?: string; 19 | 20 | /** 21 | * An XML document generated by an identity provider (IdP) that supports SAML 2.0. 22 | * 23 | * The document includes the issuer's name, expiration information, and keys that 24 | * can be used to validate the SAML authentication response (assertions) that are 25 | * received from the IdP. You must generate the metadata document using the identity 26 | * management software that is used as your organization's IdP. 27 | */ 28 | readonly metadataDocument: string; 29 | } 30 | 31 | /** 32 | * Create a SAML identity provider 33 | * 34 | * @deprecated use `SamlProvider` from `aws-cdk-lib/aws-iam` 35 | */ 36 | export class SamlIdentityProvider extends Construct { 37 | /** 38 | * The ARN of the SAML identity provider 39 | */ 40 | public readonly samlIdentityProviderArn: string; 41 | 42 | constructor(scope: Construct, id: string, props: SamlIdentityProviderProps) { 43 | super(scope, id); 44 | 45 | const name = props.name ?? `${Names.uniqueId(this)}IdentityProvider`; 46 | 47 | const arn = Stack.of(this).formatArn({ 48 | service: 'iam', 49 | region: '', 50 | resource: 'saml-provider', 51 | resourceName: name, 52 | }); 53 | 54 | const idp = new cr.AwsCustomResource(this, 'Resource', { 55 | resourceType: 'Custom::SamlIdentityProvider', 56 | onCreate: { 57 | service: 'IAM', 58 | action: 'createSAMLProvider', 59 | parameters: { 60 | Name: name, 61 | SAMLMetadataDocument: props.metadataDocument, 62 | } as CreateSAMLProviderCommandInput, 63 | physicalResourceId: cr.PhysicalResourceId.fromResponse('SAMLProviderArn'), 64 | }, 65 | onUpdate: { 66 | service: 'IAM', 67 | action: 'updateSAMLProvider', 68 | parameters: { 69 | SAMLProviderArn: new cr.PhysicalResourceIdReference().toJSON(), 70 | SAMLMetadataDocument: props.metadataDocument, 71 | } as UpdateSAMLProviderCommandInput, 72 | physicalResourceId: cr.PhysicalResourceId.fromResponse('SAMLProviderArn'), 73 | }, 74 | onDelete: { 75 | service: 'IAM', 76 | action: 'deleteSAMLProvider', 77 | parameters: { 78 | SAMLProviderArn: new cr.PhysicalResourceIdReference().toJSON(), 79 | } as DeleteSAMLProviderCommandInput, 80 | }, 81 | policy: cr.AwsCustomResourcePolicy.fromStatements([ 82 | new iam.PolicyStatement({ 83 | actions: [ 84 | 'iam:createSAMLProvider', 85 | 'iam:updateSAMLProvider', 86 | 'iam:deleteSAMLProvider', 87 | ], 88 | resources: [arn], 89 | }), 90 | ]), 91 | }); 92 | 93 | this.samlIdentityProviderArn = idp.getResponseField('SAMLProviderArn'); 94 | } 95 | } 96 | 97 | /** 98 | * Principal entity that represents a SAML federated identity provider. 99 | * 100 | * @deprecated use `SamlPrincipal` from `aws-cdk-lib/aws-iam` 101 | */ 102 | export class SamlFederatedPrincipal extends iam.FederatedPrincipal { 103 | constructor(identityProvider: SamlIdentityProvider) { 104 | super( 105 | identityProvider.samlIdentityProviderArn, 106 | { 107 | StringEquals: { 108 | 'SAML:aud': 'https://signin.aws.amazon.com/saml', 109 | }, 110 | }, 111 | 'sts:AssumeRoleWithSAML', 112 | ); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/slack-app/README.md: -------------------------------------------------------------------------------- 1 | # SlackApp 2 | 3 | Custom resource to create a Slack App from a [manifest](https://api.slack.com/reference/manifests). 4 | 5 | ## Usage 6 | 7 | Define a `SlackApp`: 8 | 9 | ```ts 10 | import { Stack, StackProps } from 'aws-cdk-lib'; 11 | import * as cloudstructs from 'cloudstructs'; 12 | import { Construct } from 'constructs'; 13 | 14 | export class MyStack extends Stack { 15 | constructor(scope: Construct, id: string, props?: StackProps) { 16 | super(scope, id, props); 17 | 18 | new cloudstructs.SlackApp(this, 'MyApp', { 19 | configurationTokenSecret: secretsmanager.Secret.fromSecretNameV2(this, 'Secret', 'slack-app-config-token'), 20 | manifest: SlackAppManifestDefinition.fromManifest({ 21 | name: 'My App', 22 | description: 'A very cool Slack App deployed with CDK', 23 | interactivity: { 24 | requestUrl: myApi.url, // reference other construct's properties 25 | }, 26 | }), 27 | }); 28 | } 29 | } 30 | ``` 31 | 32 | The secret `slack-app-config-token` is expected to be of the following form: 33 | 34 | ```json 35 | { 36 | "refreshToken": "xoxe-1-..." 37 | } 38 | ``` 39 | 40 | Go to [App configuration tokens](https://api.slack.com/authentication/config-tokens) to create 41 | a refresh token. The construct will automatically use the refresh token to retrieve a new access 42 | token when needed. 43 | 44 | By default, the construct creates an AWS Secrets Manager secret and stores the app credentials in 45 | it. You can use your own secret by specifying the `credentialsSecret` prop. 46 | 47 | ## Consuming app credentials 48 | 49 | The `credentials` property of the `SlackApp` exposes a `secretsmanager.Secret` with the app 50 | credentials. The secret has the following form: 51 | 52 | ```json 53 | { 54 | "appId": "...", 55 | "clientId": "...", 56 | "clientSecret": "...", 57 | "verificationToken": "...", 58 | "signingSecret": "..." 59 | } 60 | ``` 61 | 62 | The credentials are also exposed as individual properties that create CloudFormation 63 | [dynamic references](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/dynamic-references.html): 64 | 65 | ```ts 66 | const myApp = new cloudstructs.SlackApp(this, 'MyApp', { ... }); 67 | 68 | myLambda.addEnvironment('CLIENT_ID', myApp.clientId); 69 | ``` 70 | -------------------------------------------------------------------------------- /src/slack-app/index.ts: -------------------------------------------------------------------------------- 1 | export * from './slack-app'; 2 | export * from './manifest'; 3 | -------------------------------------------------------------------------------- /src/slack-app/provider-function.ts: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | import * as path from 'path'; 3 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 4 | import { Construct } from 'constructs'; 5 | 6 | /** 7 | * Props for ProviderFunction 8 | */ 9 | export interface ProviderFunctionProps extends lambda.FunctionOptions { 10 | } 11 | 12 | /** 13 | * An AWS Lambda function which executes src/slack-app/provider. 14 | */ 15 | export class ProviderFunction extends lambda.Function { 16 | constructor(scope: Construct, id: string, props?: ProviderFunctionProps) { 17 | super(scope, id, { 18 | description: 'src/slack-app/provider.lambda.ts', 19 | ...props, 20 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS), 21 | handler: 'index.handler', 22 | code: lambda.Code.fromAsset(path.join(__dirname, '../../assets/slack-app/provider.lambda')), 23 | }); 24 | this.addEnvironment('AWS_NODEJS_CONNECTION_REUSE_ENABLED', '1', { removeInEdge: true }); 25 | } 26 | } -------------------------------------------------------------------------------- /src/slack-app/provider.lambda.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { GetSecretValueCommand, PutSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; 3 | import type { OnEventRequest, OnEventResponse } from 'aws-cdk-lib/custom-resources/lib/provider-framework/types'; 4 | import got from 'got'; 5 | 6 | interface SlackSecret { 7 | accessToken?: string; 8 | refreshToken?: string; 9 | exp?: number; 10 | } 11 | 12 | interface RotateResponse { 13 | ok: boolean; 14 | error?: string; 15 | token: string; 16 | refresh_token: string; 17 | team_id: string; 18 | user_id: string; 19 | iat: number; 20 | exp: number; 21 | } 22 | 23 | interface ManifestCreateRequest { 24 | manifest: string; 25 | } 26 | 27 | interface ManifestUpdateRequest extends ManifestCreateRequest { 28 | app_id: string; 29 | } 30 | 31 | interface ManifestDeleteRequest { 32 | app_id: string; 33 | } 34 | 35 | type ManifestRequest = ManifestCreateRequest | ManifestUpdateRequest | ManifestDeleteRequest; 36 | 37 | interface ManifestResponse { 38 | ok: boolean; 39 | error?: string; 40 | errors?: { 41 | message: string; 42 | pointer: string; 43 | }[]; 44 | app_id?: string; 45 | credentials?: { 46 | client_id: string; 47 | client_secret: string; 48 | verification_token: string; 49 | signing_secret: string; 50 | }; 51 | } 52 | 53 | const secretsmanagerClient = new SecretsManagerClient({}); 54 | 55 | const slackClient = got.extend({ 56 | prefixUrl: 'https://slack.com/api', 57 | }); 58 | 59 | export async function handler(event: OnEventRequest): Promise { 60 | console.log('Event: %j', event); 61 | 62 | const data = await secretsmanagerClient.send(new GetSecretValueCommand({ 63 | SecretId: event.ResourceProperties.configurationTokenSecretArn, 64 | })); 65 | 66 | if (!data.SecretString) { 67 | throw new Error('No secret string found in configuration token secret'); 68 | } 69 | 70 | const secret: SlackSecret = JSON.parse(data.SecretString); 71 | 72 | let accessToken = secret.accessToken; 73 | 74 | if (!accessToken || isExpired(secret.exp ?? 0)) { 75 | if (!secret.refreshToken) { 76 | throw new Error('No refresh token found in configuration token secret'); 77 | } 78 | 79 | console.log('Refreshing access token'); 80 | const rotate = await slackClient.get('tooling.tokens.rotate', { 81 | searchParams: { refresh_token: secret.refreshToken }, 82 | }).json(); 83 | 84 | if (!rotate.ok) { 85 | throw new Error(`Failed to refresh access token: ${rotate.error}`); 86 | } 87 | console.log('Access token refreshed'); 88 | 89 | accessToken = rotate.token; 90 | 91 | console.log('Saving access token'); 92 | const putSecretValue = await secretsmanagerClient.send(new PutSecretValueCommand({ 93 | SecretId: event.ResourceProperties.configurationTokenSecretArn, 94 | SecretString: JSON.stringify({ 95 | accessToken, 96 | refreshToken: rotate.refresh_token, 97 | exp: rotate.exp, 98 | }), 99 | })); 100 | console.log(`Successfully saved access token in secret ${putSecretValue.ARN}`); 101 | } 102 | 103 | const operation = event.RequestType.toLowerCase(); 104 | const request = getManifestRequest(event); 105 | 106 | console.log(`Calling ${operation} manifest API: %j`, request); 107 | const response = await slackClient.post(`apps.manifest.${operation}`, { 108 | headers: { Authorization: `Bearer ${accessToken}` }, 109 | json: request, 110 | }).json(); 111 | 112 | if (!response.ok) { 113 | const errors = response.errors 114 | ? response.errors.map((err) => `${err.message} at ${err.pointer}`).join('\n') 115 | : ''; 116 | throw new Error(`Failed to ${operation} manifest: ${response.error}.${errors ? `\n${errors}}` : ''}`); 117 | } 118 | 119 | console.log(`Successfully ${operation}d Slack app ${event.PhysicalResourceId ?? response.app_id}`); 120 | 121 | if (event.RequestType === 'Create' && response.credentials) { 122 | console.log('Saving app credentials'); 123 | const putSecretValue = await secretsmanagerClient.send(new PutSecretValueCommand({ 124 | SecretId: event.ResourceProperties.credentialsSecretArn, 125 | SecretString: JSON.stringify({ 126 | appId: response.app_id, 127 | clientId: response.credentials.client_id, 128 | clientSecret: response.credentials.client_secret, 129 | verificationToken: response.credentials.verification_token, 130 | signingSecret: response.credentials.signing_secret, 131 | }), 132 | })); 133 | console.log(`Successfully saved app credentials in secret ${putSecretValue.ARN}`); 134 | } 135 | 136 | return { 137 | PhysicalResourceId: response.app_id, 138 | Data: { 139 | appId: response.app_id, 140 | }, 141 | }; 142 | } 143 | 144 | function isExpired(iat: number) { 145 | return (iat - (Date.now() / 1000)) < 0; 146 | } 147 | 148 | function getManifestRequest(event: OnEventRequest): ManifestRequest { 149 | switch (event.RequestType) { 150 | case 'Create': 151 | return { 152 | manifest: event.ResourceProperties.manifest, 153 | }; 154 | case 'Update': 155 | return { 156 | app_id: event.PhysicalResourceId, 157 | manifest: event.ResourceProperties.manifest, 158 | }; 159 | case 'Delete': 160 | return { 161 | app_id: event.PhysicalResourceId!, 162 | }; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/slack-app/provider.ts: -------------------------------------------------------------------------------- 1 | import { Stack } from 'aws-cdk-lib'; 2 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 3 | import * as logs from 'aws-cdk-lib/aws-logs'; 4 | import * as cr from 'aws-cdk-lib/custom-resources'; 5 | import { Construct } from 'constructs'; 6 | import { ProviderFunction } from './provider-function'; 7 | 8 | export class SlackAppProvider extends Construct { 9 | /** 10 | * Creates a stack-singleton resource provider 11 | */ 12 | public static getOrCreate(scope: Construct): SlackAppProvider { 13 | const stack = Stack.of(scope); 14 | const uid = 'SlackAppProvider'; 15 | return stack.node.tryFindChild(uid) as SlackAppProvider ?? new SlackAppProvider(stack, uid); 16 | } 17 | 18 | public readonly serviceToken: string; 19 | 20 | public readonly handler: lambda.Function; 21 | 22 | constructor(scope: Construct, id: string) { 23 | super(scope, id); 24 | 25 | this.handler = new ProviderFunction(this, 'handler', { 26 | logRetention: logs.RetentionDays.ONE_MONTH, 27 | }); 28 | 29 | const provider = new cr.Provider(this, 'Resource', { 30 | onEventHandler: this.handler, 31 | }); 32 | 33 | this.serviceToken = provider.serviceToken; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/slack-app/slack-app.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { CustomResource } from 'aws-cdk-lib'; 3 | import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; 4 | import { Construct, IConstruct } from 'constructs'; 5 | import { SlackAppManifest, SlackAppManifestProps } from './manifest'; 6 | import { SlackAppProvider } from './provider'; 7 | 8 | /** 9 | * Properties for a SlackApp 10 | */ 11 | export interface SlackAppProps { 12 | /** 13 | * The definition of the app manifest 14 | * 15 | * @see https://api.slack.com/reference/manifests 16 | */ 17 | readonly manifest: SlackAppManifestDefinition; 18 | 19 | /** 20 | * An AWS Secrets Manager secret containing the app configuration token 21 | * 22 | * Must use the following JSON format: 23 | * 24 | * ``` 25 | * { 26 | * "refreshToken": "" 27 | * } 28 | * ``` 29 | */ 30 | readonly configurationTokenSecret: secretsmanager.ISecret; 31 | 32 | /** 33 | * The AWS Secrets Manager secret where to store the app credentials 34 | * 35 | * @default - a new secret is created 36 | */ 37 | readonly credentialsSecret?: secretsmanager.ISecret; 38 | } 39 | 40 | /** 41 | * A Slack app manifest definition 42 | */ 43 | export abstract class SlackAppManifestDefinition { 44 | /** 45 | * Create a Slack app manifest from a JSON app manifest encoded as a string 46 | */ 47 | public static fromString(manifest: string): SlackAppManifestDefinition { 48 | return new StringManifest(manifest); 49 | } 50 | 51 | /** 52 | * Creates a Slack app manifest from a file containg a JSON app manifest 53 | */ 54 | public static fromFile(file: string): SlackAppManifestDefinition { 55 | return new FileManifest(file); 56 | } 57 | 58 | /** 59 | * Creates a Slack app manifest by specifying properties 60 | */ 61 | public static fromManifest(props: SlackAppManifestProps): SlackAppManifestDefinition { 62 | return new SlackAppManifest(props); 63 | } 64 | 65 | /** 66 | * Renders the JSON app manifest encoded as a string 67 | */ 68 | public abstract render(construct: IConstruct): string; 69 | } 70 | 71 | class StringManifest extends SlackAppManifestDefinition { 72 | constructor(private readonly manifest: string) { 73 | super(); 74 | } 75 | 76 | public render(_construct: IConstruct): string { 77 | return this.manifest; 78 | } 79 | } 80 | 81 | class FileManifest extends SlackAppManifestDefinition { 82 | constructor(private readonly file: string) { 83 | super(); 84 | } 85 | 86 | public render(_construct: IConstruct): string { 87 | return fs.readFileSync(this.file, 'utf8'); 88 | } 89 | } 90 | 91 | /** 92 | * A Slack application deployed with a manifest 93 | * 94 | * @see https://api.slack.com/reference/manifests 95 | */ 96 | export class SlackApp extends Construct { 97 | /** 98 | * The ID of the application 99 | */ 100 | public readonly appId: string; 101 | 102 | /** 103 | * An AWS Secrets Manager secret containing the credentials of the application. 104 | * 105 | * ``` 106 | * { 107 | * "appId": "...", 108 | * "clientId": "...", 109 | * "clientSecret": "...", 110 | * "verificationToken": "...", 111 | * "signingSecret": "..." 112 | * } 113 | * ``` 114 | */ 115 | public readonly credentials: secretsmanager.ISecret; 116 | 117 | /** 118 | * A dynamic reference to the client ID of the app 119 | */ 120 | public readonly clientId: string; 121 | 122 | /** 123 | * A dynamic reference to the client secret of the app 124 | */ 125 | public readonly clientSecret: string; 126 | 127 | /** 128 | * A dynamic reference to the verification token of the app 129 | */ 130 | public readonly verificationToken: string; 131 | 132 | /** 133 | * A dynamic reference to the signing secret of the app 134 | */ 135 | public readonly signingSecret: string; 136 | 137 | constructor(scope: Construct, id: string, props: SlackAppProps) { 138 | super(scope, id); 139 | 140 | const provider = SlackAppProvider.getOrCreate(this); 141 | props.configurationTokenSecret.grantRead(provider.handler); 142 | props.configurationTokenSecret.grantWrite(provider.handler); 143 | 144 | this.credentials = props.credentialsSecret ?? new secretsmanager.Secret(this, 'Credentials', { 145 | description: `Credentials for Slack App ${this.node.id}`, 146 | }); 147 | this.credentials.grantWrite(provider.handler); 148 | 149 | const resource = new CustomResource(this, 'Resource', { 150 | serviceToken: provider.serviceToken, 151 | resourceType: 'Custom::SlackApp', 152 | properties: { 153 | manifest: props.manifest.render(this), 154 | configurationTokenSecretArn: props.configurationTokenSecret.secretArn, 155 | credentialsSecretArn: this.credentials.secretArn, 156 | }, 157 | }); 158 | 159 | this.appId = resource.getAttString('appId'); 160 | this.clientId = this.credentials.secretValueFromJson('clientId').toString(); 161 | this.clientSecret = this.credentials.secretValueFromJson('clientSecret').toString(); 162 | this.verificationToken = this.credentials.secretValueFromJson('verificationToken').toString(); 163 | this.signingSecret = this.credentials.secretValueFromJson('signingSecret').toString(); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/slack-events/README.md: -------------------------------------------------------------------------------- 1 | # SlackEvents 2 | 3 | Send Slack events to Amazon EventBridge. 4 | 5 | ## Installation 6 | 7 | ### 1. Create a Slack app 8 | 9 | Create a new [Slack app](https://api.slack.com/apps) in your workspace, install it and 10 | save its signing secret in a [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) 11 | secret. 12 | 13 | This can be done with the [AWS CLI](https://aws.amazon.com/cli/): 14 | 15 | ``` 16 | aws secretsmanager create-secret --name my-slack-app --secret-string 17 | ``` 18 | 19 | ### 2. Add the SlackEvents construct 20 | 21 | Define a `SlackEvents` in your `Stack` and deploy it: 22 | 23 | ```ts 24 | import { Stack, StackProps } from 'aws-cdk-lib'; 25 | import * as cloudstructs from 'cloudstructs'; 26 | import { Construct } from 'constructs'; 27 | 28 | export class MyStack extends Stack { 29 | constructor(scope: Construct, id: string, props?: StackProps) { 30 | super(scope, id, props); 31 | 32 | new cloudstructs.SlackEvents(this, 'SlackEvents', { 33 | signingSecret: cdk.SecretValue.secretsManager('my-slack-app'), 34 | }); 35 | } 36 | } 37 | ``` 38 | 39 | ### 3. Connect your Slack app to the deployed API 40 | 41 | Look for the API endoint in your stack outputs and use it to enable event subscriptions 42 | in your Slack app. At this point you can also finish configuring the scopes of your Slack 43 | app and add it to the channels where you want to listen for events. 44 | 45 | ### 4. Intercept Slack events 46 | 47 | You can now intercept [Slack events](https://api.slack.com/events) using a `events.Rule`: 48 | 49 | ```ts 50 | const fileSharedRule = new events.Rule(this, 'SlackEventsRule', { 51 | eventPattern: { 52 | detail: { 53 | event: { 54 | type: ['file_shared'], 55 | }, 56 | }, 57 | resources: [''], 58 | source: ['slack'], 59 | }, 60 | }); 61 | 62 | fileSharedRule.addTarget(new targets.LambdaFunction(myLambda, { 63 | event: events.RuleTargetInput.fromEventPath('$.detail.event'), 64 | })); 65 | ``` 66 | 67 | ## Use a custom event bus 68 | By default events are sent to your default event bus, which receives events emitted 69 | by AWS services. 70 | 71 | Set the `customEventBus` to `true` to create and send events to a 72 | [custom event bus](https://docs.aws.amazon.com/eventbridge/latest/userguide/create-event-bus.html) 73 | 74 | ```ts 75 | const slackEvents = new cloudstructs.SlackEvents(this, 'SlackEvents', { 76 | signingSecret: cdk.SecretValue.secretsManager('my-slack-app'), 77 | customEventBus: true, 78 | }); 79 | 80 | const fileSharedRule = new events.Rule(this, 'SlackEventsRule', { 81 | eventBus: slackEvents.eventBus, 82 | eventPattern: { 83 | detail: { 84 | event: { 85 | type: ['file_shared'], 86 | }, 87 | }, 88 | resources: [''], 89 | source: ['slack'], 90 | }, 91 | }); 92 | ``` 93 | -------------------------------------------------------------------------------- /src/slack-events/events-function.ts: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | import * as path from 'path'; 3 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 4 | import { Construct } from 'constructs'; 5 | 6 | /** 7 | * Props for EventsFunction 8 | */ 9 | export interface EventsFunctionProps extends lambda.FunctionOptions { 10 | } 11 | 12 | /** 13 | * An AWS Lambda function which executes src/slack-events/events. 14 | */ 15 | export class EventsFunction extends lambda.Function { 16 | constructor(scope: Construct, id: string, props?: EventsFunctionProps) { 17 | super(scope, id, { 18 | description: 'src/slack-events/events.lambda.ts', 19 | ...props, 20 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS), 21 | handler: 'index.handler', 22 | code: lambda.Code.fromAsset(path.join(__dirname, '../../assets/slack-events/events.lambda')), 23 | }); 24 | this.addEnvironment('AWS_NODEJS_CONNECTION_REUSE_ENABLED', '1', { removeInEdge: true }); 25 | } 26 | } -------------------------------------------------------------------------------- /src/slack-events/events.lambda.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge'; 3 | import { verifyRequestSignature } from './signature'; 4 | 5 | const eventBridgeClient = new EventBridgeClient({}); 6 | 7 | /** 8 | * Handle Slack events 9 | */ 10 | export async function handler(event: AWSLambda.APIGatewayProxyEvent): Promise { 11 | console.log('Event: %j', event); 12 | 13 | // Base API gateway response 14 | const response: AWSLambda.APIGatewayProxyResult = { 15 | statusCode: 200, 16 | body: '', 17 | }; 18 | 19 | try { 20 | if (!process.env.SLACK_SIGNING_SECRET) throw new Error('The environment variable SLACK_SIGNING_SECRET is not defined'); 21 | 22 | if (!event.body) throw new Error('Missing body'); 23 | 24 | if (!event.headers['X-Slack-Signature']) throw new Error('Missing X-Slack-Signature'); 25 | 26 | if (!event.headers['X-Slack-Request-Timestamp']) throw new Error('Missing X-Slack-Request-Timestamp'); 27 | 28 | if (!verifyRequestSignature({ 29 | body: event.body, 30 | requestSignature: event.headers['X-Slack-Signature'], 31 | requestTimestamp: parseInt(event.headers['X-Slack-Request-Timestamp'], 10), 32 | signingSecret: process.env.SLACK_SIGNING_SECRET, 33 | })) { 34 | response.statusCode = 403; 35 | return response; 36 | } 37 | 38 | const body = JSON.parse(event.body); 39 | console.log('Body: %j', body); 40 | 41 | if (body.type === 'url_verification') { // Slack URL verification, respond with challenge 42 | console.log('URL verification'); 43 | response.body = JSON.stringify({ challenge: body.challenge }); 44 | return response; 45 | } 46 | 47 | const putEvents = await eventBridgeClient.send(new PutEventsCommand({ 48 | Entries: [{ 49 | Detail: event.body, 50 | DetailType: 'Slack Event', 51 | Source: 'slack', 52 | Resources: [body.api_app_id], 53 | EventBusName: process.env.EVENT_BUS_NAME, 54 | Time: new Date(body.event_time), 55 | }], 56 | })); 57 | console.log('Put events: %j', putEvents); 58 | 59 | return response; 60 | } catch (err) { 61 | console.log(err); 62 | response.statusCode = 500; 63 | return response; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/slack-events/index.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2'; 3 | import * as integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations'; 4 | import * as events from 'aws-cdk-lib/aws-events'; 5 | import * as logs from 'aws-cdk-lib/aws-logs'; 6 | import { Construct } from 'constructs'; 7 | import { EventsFunction } from './events-function'; 8 | 9 | /** 10 | * Properties for a SlackEvents 11 | */ 12 | export interface SlackEventsProps { 13 | /** 14 | * The signing secret of the Slack app 15 | */ 16 | readonly signingSecret: cdk.SecretValue; 17 | 18 | /** 19 | * A name for the API Gateway resource 20 | * 21 | * @default SlackEventsApi 22 | */ 23 | readonly apiName?: string; 24 | 25 | /** 26 | * Whether to use a custom event bus 27 | * 28 | * @default false 29 | */ 30 | readonly customEventBus?: boolean; 31 | } 32 | 33 | /** 34 | * Send Slack events to Amazon EventBridge 35 | */ 36 | export class SlackEvents extends Construct { 37 | /** 38 | * The custom event bus where Slack events are sent 39 | */ 40 | public readonly eventBus?: events.EventBus; 41 | 42 | constructor(scope: Construct, id: string, props: SlackEventsProps) { 43 | super(scope, id); 44 | 45 | if (props.customEventBus) { 46 | this.eventBus = new events.EventBus(this, 'EventBus'); 47 | } 48 | 49 | // Send event to the event bus 50 | const handler = new EventsFunction(this, 'handler', { 51 | logRetention: logs.RetentionDays.ONE_MONTH, 52 | environment: { 53 | SLACK_SIGNING_SECRET: props.signingSecret.toString(), 54 | }, 55 | }); 56 | 57 | if (this.eventBus) { 58 | handler.addEnvironment('EVENT_BUS_NAME', this.eventBus.eventBusName); 59 | } 60 | 61 | events.EventBus.grantAllPutEvents(handler); 62 | 63 | // HTTP API 64 | const httpApi = new apigatewayv2.HttpApi(this, 'SlackEventsApi', { 65 | defaultIntegration: new integrations.HttpLambdaIntegration('Integration', handler), 66 | apiName: props.apiName, 67 | }); 68 | 69 | new cdk.CfnOutput(this, 'ApiEndpoint', { 70 | value: httpApi.apiEndpoint, 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/slack-events/signature.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | 3 | export interface VerifyRequestSignatureOptions { 4 | readonly body: string; 5 | readonly requestSignature: string; 6 | readonly requestTimestamp: number; 7 | readonly signingSecret: string; 8 | } 9 | 10 | export function verifyRequestSignature(options: VerifyRequestSignatureOptions): boolean { 11 | const fiveMinutesAgo = Math.floor(Date.now() / 1000) - (60 * 5); 12 | 13 | if (options.requestTimestamp < fiveMinutesAgo) { 14 | console.error('Slack request signing verification outdated'); 15 | return false; 16 | } 17 | 18 | const hmac = crypto.createHmac('sha256', options.signingSecret); 19 | const [version, hash] = options.requestSignature.split('='); 20 | hmac.update(`${version}:${options.requestTimestamp}:${options.body}`); 21 | const hex = hmac.digest('hex'); 22 | 23 | if (hash.length !== hex.length || 24 | !crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(hex))) { 25 | console.error('Slack request signing verification failed'); 26 | return false; 27 | } 28 | 29 | return true; 30 | } 31 | -------------------------------------------------------------------------------- /src/slack-textract/README.md: -------------------------------------------------------------------------------- 1 | # SlackTextract 2 | 3 | Extract text from images posted to Slack using Amazon Textract. The extracted 4 | text is posted in a thread under the image and gets indexed! 5 | 6 | ## Installation 7 | 8 | ### 1. Create a Slack app 9 | 10 | Create a new [Slack app](https://api.slack.com/apps) in your workspace, add the 11 | `chat:write` and `files:read` bot scopes, install it and save its app id, bot token 12 | and signing secret in a [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) 13 | secret. 14 | 15 | This can be done with the [AWS CLI](https://aws.amazon.com/cli/): 16 | 17 | ``` 18 | aws secretsmanager create-secret --name my-slack-app --secret-string '{"appId":"","signingSecret":"","botToken":""}' 19 | ``` 20 | 21 | ### 2. Add the SlackTextract construct 22 | 23 | Define a `SlackTextract` in your `Stack` and deploy it: 24 | 25 | ```ts 26 | import { SecretValue, Stack, StackProps } from 'aws-cdk-lib'; 27 | import * as cloudstructs from 'cloudstructs'; 28 | import { Construct } from 'constructs'; 29 | 30 | export class MyStack extends Stack { 31 | constructor(scope: Construct, id: string, props?: StackProps) { 32 | super(scope, id, props); 33 | 34 | new cloudstructs.SlackTextract(this, 'SlackTextract', { 35 | signingSecret: SecretValue.secretsManager('my-slack-app', { jsonField: 'signingSecret' }), 36 | appId: SecretValue.secretsManager('my-slack-app', { jsonField: 'appId' }).toString(), 37 | botToken: SecretValue.secretsManager('my-slack-app', { jsonField: 'botToken' }), 38 | }); 39 | } 40 | } 41 | ``` 42 | 43 | ### 3. Connect your Slack app to the deployed API 44 | 45 | Look for the API endoint in your stack outputs and use it to enable event subscriptions 46 | in your Slack app and subscribe to the `file_shared` events. 47 | 48 | ### 4. Add your app to a channel 49 | 50 | Add your app to a channel, share an image and let the magic happen: 51 | 52 |

53 | 54 |

55 | -------------------------------------------------------------------------------- /src/slack-textract/detect-function.ts: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | import * as path from 'path'; 3 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 4 | import { Construct } from 'constructs'; 5 | 6 | /** 7 | * Props for DetectFunction 8 | */ 9 | export interface DetectFunctionProps extends lambda.FunctionOptions { 10 | } 11 | 12 | /** 13 | * An AWS Lambda function which executes src/slack-textract/detect. 14 | */ 15 | export class DetectFunction extends lambda.Function { 16 | constructor(scope: Construct, id: string, props?: DetectFunctionProps) { 17 | super(scope, id, { 18 | description: 'src/slack-textract/detect.lambda.ts', 19 | ...props, 20 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS), 21 | handler: 'index.handler', 22 | code: lambda.Code.fromAsset(path.join(__dirname, '../../assets/slack-textract/detect.lambda')), 23 | }); 24 | this.addEnvironment('AWS_NODEJS_CONNECTION_REUSE_ENABLED', '1', { removeInEdge: true }); 25 | } 26 | } -------------------------------------------------------------------------------- /src/slack-textract/detect.lambda.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { DetectDocumentTextCommand, TextractClient } from '@aws-sdk/client-textract'; 3 | import { WebClient } from '@slack/web-api'; 4 | import got from 'got'; 5 | 6 | export interface SlackEvent { 7 | channel_id: string; 8 | file_id: string; 9 | } 10 | 11 | const textractClient = new TextractClient({}); 12 | 13 | export async function handler(event: SlackEvent): Promise { 14 | console.log('Event: %j', event); 15 | 16 | const slackClient = new WebClient(process.env.SLACK_TOKEN); 17 | 18 | // Get file info 19 | const info = await slackClient.files.info({ 20 | file: event.file_id, 21 | }); 22 | console.log('File info: %j', info); 23 | 24 | if (!info.file) { 25 | console.log('No file'); 26 | return; 27 | } 28 | 29 | if (!info.file.mimetype?.startsWith('image')) { 30 | console.log('Not an image'); 31 | return; 32 | } 33 | 34 | if (!info.file.url_private) { 35 | console.log('No private URL'); 36 | return; 37 | } 38 | 39 | // Get file 40 | const file = await got(info.file.url_private, { 41 | headers: { 42 | Authorization: `Bearer ${process.env.SLACK_TOKEN}`, 43 | }, 44 | }).buffer(); 45 | 46 | // Detect text with Textract 47 | const data = await textractClient.send(new DetectDocumentTextCommand({ 48 | Document: { Bytes: file }, 49 | })); 50 | 51 | if (!data.Blocks) { 52 | console.log('No text detected'); 53 | return; 54 | } 55 | 56 | // Add detected text in image thread 57 | const postMessage = await slackClient.chat.postMessage({ 58 | channel: event.channel_id, 59 | text: data.Blocks.filter((b) => b.BlockType === 'LINE').map((b) => b.Text).join('\n'), 60 | thread_ts: info.file.shares?.public?.[event.channel_id][0].ts, 61 | }); 62 | console.log('Post message: %j', postMessage); 63 | } 64 | -------------------------------------------------------------------------------- /src/slack-textract/index.ts: -------------------------------------------------------------------------------- 1 | import { Duration, SecretValue } from 'aws-cdk-lib'; 2 | import * as events from 'aws-cdk-lib/aws-events'; 3 | import * as targets from 'aws-cdk-lib/aws-events-targets'; 4 | import * as iam from 'aws-cdk-lib/aws-iam'; 5 | import * as logs from 'aws-cdk-lib/aws-logs'; 6 | import { Construct } from 'constructs'; 7 | import { DetectFunction } from './detect-function'; 8 | import { SlackEvents } from '../slack-events'; 9 | 10 | /** 11 | * Properties for a SlackTextract 12 | */ 13 | export interface SlackTextractProps { 14 | /** 15 | * The signing secret of the Slack app 16 | */ 17 | readonly signingSecret: SecretValue; 18 | 19 | /** 20 | * The **bot** token of the Slack app. 21 | * 22 | * The following scopes are required: `chat:write` and `files:read` 23 | */ 24 | readonly botToken: SecretValue; 25 | 26 | /** 27 | * The application id of the Slack app. 28 | */ 29 | readonly appId: string; 30 | } 31 | 32 | /** 33 | * Extract text from images posted to Slack using Amazon Textract 34 | */ 35 | export class SlackTextract extends Construct { 36 | constructor(scope: Construct, id: string, props: SlackTextractProps) { 37 | super(scope, id); 38 | 39 | const handler = new DetectFunction(this, 'handler', { 40 | timeout: Duration.seconds(30), 41 | logRetention: logs.RetentionDays.ONE_MONTH, 42 | environment: { 43 | SLACK_TOKEN: props.botToken.toString(), 44 | }, 45 | }); 46 | 47 | handler.addToRolePolicy(new iam.PolicyStatement({ 48 | actions: ['textract:DetectDocumentText'], 49 | resources: ['*'], 50 | })); 51 | 52 | new SlackEvents(this, 'SlackEvents', { 53 | signingSecret: props.signingSecret, 54 | }); 55 | 56 | const fileSharedRule = new events.Rule(this, 'SlackEventsRule', { 57 | eventPattern: { 58 | detail: { 59 | event: { 60 | type: ['file_shared'], 61 | }, 62 | }, 63 | resources: [props.appId], 64 | source: ['slack'], 65 | }, 66 | }); 67 | 68 | fileSharedRule.addTarget(new targets.LambdaFunction(handler, { 69 | event: events.RuleTargetInput.fromEventPath('$.detail.event'), 70 | })); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/slack-textract/slack-textract.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jogold/cloudstructs/913c49dd28216423e77b59cd58272b6a131b98d9/src/slack-textract/slack-textract.gif -------------------------------------------------------------------------------- /src/ssl-server-test/README.md: -------------------------------------------------------------------------------- 1 | # SslServerTest 2 | 3 | Test a server/host for SSL/TLS on schedule and get notified when the overall 4 | rating is not satisfactory. 5 | 6 | This construct uses the [Qualys SSL Labs API](https://www.ssllabs.com). 7 | 8 | ## Usage 9 | 10 | Define a `SslServerTest`: 11 | 12 | ```ts 13 | import { Stack, StackProps } from 'aws-cdk-lib'; 14 | import { SslServerTest } from 'cloudstructs'; 15 | import { Construct } from 'constructs'; 16 | 17 | export class MyStack extends Stack { 18 | constructor(scope: Construct, id: string, props?: StackProps) { 19 | super(scope, id, props); 20 | 21 | new SslServerTest(this, 'TestMyHost', { 22 | host: 'my.host' 23 | }); 24 | } 25 | } 26 | ``` 27 | 28 | This will create a state machine that will run a SSL server test everyday. By default, a SNS topic is 29 | created and a notification is sent to this topic if the [grade](https://github.com/ssllabs/research/wiki/SSL-Server-Rating-Guide) 30 | of the test is below `A+`. The content of the notification is the 31 | [test result returned by the API](https://github.com/ssllabs/ssllabs-scan/blob/master/ssllabs-api-docs-v3.md#response-objects). 32 | 33 | ```ts 34 | const myTest = new SslServerTest(this, 'MyTest', { 35 | host: 'my.host', 36 | }); 37 | 38 | myTest.alarmTopic.addSubscription(/* your subscription here */) 39 | ``` 40 | 41 | Use the `minimumGrade`, `alarmTopic` or `schedule` props to customize the 42 | behavior of the construct. 43 | 44 |

45 | 46 |

47 | -------------------------------------------------------------------------------- /src/ssl-server-test/analyze-function.ts: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | import * as path from 'path'; 3 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 4 | import { Construct } from 'constructs'; 5 | 6 | /** 7 | * Props for AnalyzeFunction 8 | */ 9 | export interface AnalyzeFunctionProps extends lambda.FunctionOptions { 10 | } 11 | 12 | /** 13 | * An AWS Lambda function which executes src/ssl-server-test/analyze. 14 | */ 15 | export class AnalyzeFunction extends lambda.Function { 16 | constructor(scope: Construct, id: string, props?: AnalyzeFunctionProps) { 17 | super(scope, id, { 18 | description: 'src/ssl-server-test/analyze.lambda.ts', 19 | ...props, 20 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS), 21 | handler: 'index.handler', 22 | code: lambda.Code.fromAsset(path.join(__dirname, '../../assets/ssl-server-test/analyze.lambda')), 23 | }); 24 | this.addEnvironment('AWS_NODEJS_CONNECTION_REUSE_ENABLED', '1', { removeInEdge: true }); 25 | } 26 | } -------------------------------------------------------------------------------- /src/ssl-server-test/analyze.lambda.ts: -------------------------------------------------------------------------------- 1 | import got from 'got'; 2 | 3 | const sslLabsClient = got.extend({ 4 | prefixUrl: 'https://api.ssllabs.com/api/v3', 5 | }); 6 | 7 | export async function handler(event: Record) { 8 | const response = await sslLabsClient('analyze', { 9 | searchParams: event, 10 | }).json(); 11 | 12 | return response; 13 | } 14 | -------------------------------------------------------------------------------- /src/ssl-server-test/extract-grade-function.ts: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | import * as path from 'path'; 3 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 4 | import { Construct } from 'constructs'; 5 | 6 | /** 7 | * Props for ExtractGradeFunction 8 | */ 9 | export interface ExtractGradeFunctionProps extends lambda.FunctionOptions { 10 | } 11 | 12 | /** 13 | * An AWS Lambda function which executes src/ssl-server-test/extract-grade. 14 | */ 15 | export class ExtractGradeFunction extends lambda.Function { 16 | constructor(scope: Construct, id: string, props?: ExtractGradeFunctionProps) { 17 | super(scope, id, { 18 | description: 'src/ssl-server-test/extract-grade.lambda.ts', 19 | ...props, 20 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS), 21 | handler: 'index.handler', 22 | code: lambda.Code.fromAsset(path.join(__dirname, '../../assets/ssl-server-test/extract-grade.lambda')), 23 | }); 24 | this.addEnvironment('AWS_NODEJS_CONNECTION_REUSE_ENABLED', '1', { removeInEdge: true }); 25 | } 26 | } -------------------------------------------------------------------------------- /src/ssl-server-test/extract-grade.lambda.ts: -------------------------------------------------------------------------------- 1 | interface SslAnalysisResult { 2 | endpoints: { 3 | grade: string; 4 | }[]; 5 | } 6 | 7 | export async function handler(event: SslAnalysisResult) { 8 | return event.endpoints.map(e => e.grade).sort().pop(); 9 | } 10 | -------------------------------------------------------------------------------- /src/state-machine-cr-provider/README.md: -------------------------------------------------------------------------------- 1 | # StateMachineCustomResourceProvider 2 | 3 | Implement custom resources with AWS Step Functions state machines. 4 | 5 | The `StateMachineCustomResourceProvider` allows to create complex custom resources calling 6 | various AWS services and running long processes. This with minimal runtime code. 7 | 8 | For example, it can be used to run a Fargate task to provision a RDS database. 9 | 10 | ## Usage 11 | 12 | Define a `StateMachineCustomResourceProvider` and pass its `serviceToken` to a 13 | a `cdk.CustomResource`. 14 | 15 | ```ts 16 | import { CustomResource, Stack, StackProps } from 'aws-cdk-lib'; 17 | import * as cloudstructs from 'cloudstructs'; 18 | import { Construct } from 'constructs'; 19 | 20 | export class MyStack extends Stack { 21 | constructor(scope: Construct, id: string, props?: StackProps) { 22 | super(scope, id, props); 23 | 24 | // Define a provider 25 | const provider = new cloudstructs.StateMachineCustomResourceProvider(this, 'Provider', { 26 | stateMachine: myStateMachine, 27 | }); 28 | 29 | // Use the provider as a custom resource 30 | new CustomResource(this, 'CustomResource', { 31 | serviceToken: provider.serviceToken, 32 | properties: { 33 | Key: 'value', 34 | }, 35 | }); 36 | } 37 | } 38 | ``` 39 | 40 | The provider will start an execution of `myStateMachine` with the [custom resource request 41 | object](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html). 42 | 43 | The provider automatically sends back a response to CloudFormation and handles errors in case 44 | of failures or timeouts. This prevent deployments from being blocked. 45 | 46 | The following fields can be specified in the execution output of `myStateMachine`: 47 | 48 | * `PhysicalResourceId`: The allocated/assigned physical ID of the resource. If omitted for `Create` 49 | events, the event's `RequestId` will be used. For `Update`, the current physical ID will be used. 50 | If a different value is returned, CloudFormation will follow with a subsequent `Delete` for the previous ID (resource replacement). For `Delete`, it will always return the current physical 51 | resource ID. 52 | 53 | * `Data`: Resource attributes, which can later be retrieved through `Fn::GetAtt` on the custom 54 | resource object. 55 | 56 | * `NoEcho`: Indicates whether to mask the output of the custom resource when retrieved by using 57 | the `Fn::GetAtt` function. The default value is `false`. 58 | -------------------------------------------------------------------------------- /src/state-machine-cr-provider/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { ArnFormat, CfnResource, Duration, Stack } from 'aws-cdk-lib'; 3 | import * as iam from 'aws-cdk-lib/aws-iam'; 4 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 5 | import { Construct } from 'constructs'; 6 | 7 | /** 8 | * A State Machine 9 | */ 10 | export interface IStateMachine { 11 | /** 12 | * The ARN of the state machine 13 | */ 14 | readonly stateMachineArn: string; 15 | } 16 | 17 | /** 18 | * Properties for a StateMachineCustomResourceProvider 19 | */ 20 | export interface StateMachineCustomResourceProviderProps { 21 | /** 22 | * The state machine 23 | */ 24 | readonly stateMachine: IStateMachine; 25 | 26 | /** 27 | * Timeout 28 | * 29 | * @default Duration.minutes(30) 30 | */ 31 | readonly timeout?: Duration; 32 | } 33 | 34 | /** 35 | * A state machine custom resource provider 36 | */ 37 | export class StateMachineCustomResourceProvider extends Construct { 38 | /** 39 | * The service token 40 | */ 41 | public readonly serviceToken: string; 42 | 43 | constructor(scope: Construct, id: string, props: StateMachineCustomResourceProviderProps) { 44 | super(scope, id); 45 | 46 | const cfnResponseSuccessFn = this.createCfnResponseFn('Success'); 47 | const cfnResponseFailedFn = this.createCfnResponseFn('Failed'); 48 | 49 | const role = new iam.Role(this, 'Role', { 50 | assumedBy: new iam.ServicePrincipal('states.amazonaws.com'), 51 | }); 52 | role.addToPolicy(new iam.PolicyStatement({ 53 | actions: ['lambda:InvokeFunction'], 54 | resources: [cfnResponseSuccessFn.functionArn, cfnResponseFailedFn.functionArn], 55 | })); 56 | // https://docs.aws.amazon.com/step-functions/latest/dg/stepfunctions-iam.html 57 | // https://docs.aws.amazon.com/step-functions/latest/dg/concept-create-iam-advanced.html#concept-create-iam-advanced-execution 58 | role.addToPolicy(new iam.PolicyStatement({ 59 | actions: ['states:StartExecution'], 60 | resources: [props.stateMachine.stateMachineArn], 61 | })); 62 | role.addToPolicy(new iam.PolicyStatement({ 63 | actions: ['states:DescribeExecution', 'states:StopExecution'], 64 | resources: [Stack.of(this).formatArn({ 65 | service: 'states', 66 | resource: 'execution', 67 | arnFormat: ArnFormat.COLON_RESOURCE_NAME, 68 | resourceName: `${Stack.of(this).splitArn(props.stateMachine.stateMachineArn, ArnFormat.COLON_RESOURCE_NAME).resourceName}*`, 69 | })], 70 | })); 71 | role.addToPolicy(new iam.PolicyStatement({ 72 | actions: ['events:PutTargets', 'events:PutRule', 'events:DescribeRule'], 73 | resources: [Stack.of(this).formatArn({ 74 | service: 'events', 75 | resource: 'rule', 76 | resourceName: 'StepFunctionsGetEventsForStepFunctionsExecutionRule', 77 | })], 78 | })); 79 | 80 | const definition = Stack.of(this).toJsonString({ 81 | StartAt: 'StartExecution', 82 | States: { 83 | StartExecution: { 84 | Type: 'Task', 85 | Resource: 'arn:aws:states:::states:startExecution.sync:2', // with sync:2 the Output is JSON parsed 86 | Parameters: { 87 | 'Input.$': '$', 88 | 'StateMachineArn': props.stateMachine.stateMachineArn, 89 | }, 90 | TimeoutSeconds: (props.timeout ?? Duration.minutes(30)).toSeconds(), 91 | Next: 'CfnResponseSuccess', 92 | Catch: [{ 93 | ErrorEquals: ['States.ALL'], 94 | Next: 'CfnResponseFailed', 95 | }], 96 | }, 97 | CfnResponseSuccess: { 98 | Type: 'Task', 99 | Resource: cfnResponseSuccessFn.functionArn, 100 | End: true, 101 | }, 102 | CfnResponseFailed: { 103 | Type: 'Task', 104 | Resource: cfnResponseFailedFn.functionArn, 105 | End: true, 106 | }, 107 | }, 108 | }); 109 | 110 | const stateMachine = new CfnResource(this, 'StateMachine', { 111 | type: 'AWS::StepFunctions::StateMachine', 112 | properties: { 113 | DefinitionString: definition, 114 | RoleArn: role.roleArn, 115 | }, 116 | }); 117 | stateMachine.node.addDependency(role); 118 | 119 | const startExecution = new lambda.Function(this, 'StartExecution', { 120 | code: lambda.Code.fromAsset(path.join(__dirname, 'runtime')), 121 | handler: 'index.startExecution', 122 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS), 123 | }); 124 | startExecution.addToRolePolicy(new iam.PolicyStatement({ 125 | actions: ['states:StartExecution'], 126 | resources: [stateMachine.ref], 127 | })); 128 | startExecution.addEnvironment('STATE_MACHINE_ARN', stateMachine.ref); 129 | 130 | this.serviceToken = startExecution.functionArn; 131 | } 132 | 133 | private createCfnResponseFn(status: string) { 134 | return new lambda.Function(this, `CfnResponse${status}`, { 135 | code: lambda.Code.fromAsset(path.join(__dirname, 'runtime')), 136 | handler: `index.cfnResponse${status}`, 137 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS), 138 | }); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/state-machine-cr-provider/runtime/http.ts: -------------------------------------------------------------------------------- 1 | import * as https from 'https'; 2 | import * as url from 'url'; 3 | 4 | export const MISSING_PHYSICAL_ID_MARKER = 'AWSCDK::StateMachineProvider::MISSING_PHYSICAL_ID'; 5 | 6 | interface CloudFormationResponse { 7 | StackId: string; 8 | RequestId: string; 9 | PhysicalResourceId?: string; 10 | LogicalResourceId: string; 11 | ResponseURL: string; 12 | Data?: any; 13 | NoEcho?: boolean; 14 | Reason?: string; 15 | } 16 | 17 | export function respond(status: 'SUCCESS' | 'FAILED', event: CloudFormationResponse) { 18 | const json: AWSLambda.CloudFormationCustomResourceResponse = { 19 | Status: status, 20 | Reason: event.Reason ?? status, 21 | PhysicalResourceId: event.PhysicalResourceId || MISSING_PHYSICAL_ID_MARKER, 22 | StackId: event.StackId, 23 | RequestId: event.RequestId, 24 | LogicalResourceId: event.LogicalResourceId, 25 | NoEcho: event.NoEcho ?? false, 26 | Data: event.Data, 27 | }; 28 | 29 | console.log('Responding: %j', json); // tslint:disable-line no-console 30 | 31 | const responseBody = JSON.stringify(json); 32 | 33 | const parsedUrl = url.parse(event.ResponseURL); 34 | const requestOptions = { 35 | hostname: parsedUrl.hostname, 36 | path: parsedUrl.path, 37 | method: 'PUT', 38 | headers: { 'content-type': '', 'content-length': responseBody.length }, 39 | }; 40 | 41 | return new Promise((resolve, reject) => { 42 | try { 43 | const request = https.request(requestOptions, resolve); 44 | request.on('error', reject); 45 | request.write(responseBody); 46 | request.end(); 47 | } catch (e) { 48 | reject(e); 49 | } 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /src/state-machine-cr-provider/runtime/index.ts: -------------------------------------------------------------------------------- 1 | import { SFNClient, StartExecutionCommand } from '@aws-sdk/client-sfn'; // eslint-disable-line import/no-extraneous-dependencies 2 | import { respond } from './http'; 3 | 4 | export const CREATE_FAILED_PHYSICAL_ID_MARKER = 'AWSCDK::StateMachineProvider::CREATE_FAILED'; 5 | 6 | interface Output { 7 | PhysicalResourceId?: string; 8 | Data?: { [Key: string]: any }; 9 | NoEcho?: boolean; 10 | } 11 | 12 | interface ExecutionResult { 13 | ExecutionArn: string; 14 | Input: AWSLambda.CloudFormationCustomResourceEvent & { PhysicalResourceId?: string }; 15 | Name: string; 16 | Output?: Output; 17 | StartDate: number; 18 | StateMachineArn: string; 19 | Status: 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'TIMED_OUT' | 'ABORTED'; 20 | StopDate: number; 21 | } 22 | 23 | interface FailedExecutionEvent { 24 | Error: string; 25 | Cause: string; 26 | } 27 | 28 | export async function cfnResponseSuccess(event: ExecutionResult) { 29 | console.log('Event: %j', event); 30 | await respond('SUCCESS', { 31 | ...event.Input, 32 | PhysicalResourceId: event.Output?.PhysicalResourceId ?? event.Input.PhysicalResourceId ?? event.Input.RequestId, 33 | Data: event.Output?.Data ?? {}, 34 | NoEcho: event.Output?.NoEcho, 35 | }); 36 | } 37 | 38 | export async function cfnResponseFailed(event: FailedExecutionEvent) { 39 | console.log('Event: %j', event); 40 | 41 | const parsedCause = JSON.parse(event.Cause); 42 | const executionResult: ExecutionResult = { 43 | ...parsedCause, 44 | Input: JSON.parse(parsedCause.Input), 45 | }; 46 | console.log('Execution result: %j', executionResult); 47 | 48 | let physicalResourceId = executionResult.Output?.PhysicalResourceId ?? executionResult.Input.PhysicalResourceId; 49 | if (!physicalResourceId) { 50 | // special case: if CREATE fails, which usually implies, we usually don't 51 | // have a physical resource id. in this case, the subsequent DELETE 52 | // operation does not have any meaning, and will likely fail as well. to 53 | // address this, we use a marker so the provider framework can simply 54 | // ignore the subsequent DELETE. 55 | if (executionResult.Input.RequestType === 'Create') { 56 | console.log('CREATE failed, responding with a marker physical resource id so that the subsequent DELETE will be ignored'); 57 | physicalResourceId = CREATE_FAILED_PHYSICAL_ID_MARKER; 58 | } else { 59 | console.log(`ERROR: Malformed event. "PhysicalResourceId" is required: ${JSON.stringify(event)}`); 60 | } 61 | } 62 | 63 | await respond('FAILED', { 64 | ...executionResult.Input, 65 | Reason: `${event.Error}: ${event.Cause}`, 66 | PhysicalResourceId: physicalResourceId, 67 | }); 68 | } 69 | 70 | const stepFunctionsClient = new SFNClient({}); 71 | 72 | export async function startExecution(event: AWSLambda.CloudFormationCustomResourceEvent) { 73 | try { 74 | console.log('Event: %j', event); 75 | 76 | if (!process.env.STATE_MACHINE_ARN) { 77 | throw new Error('Missing STATE_MACHINE_ARN.'); 78 | } 79 | 80 | // ignore DELETE event when the physical resource ID is the marker that 81 | // indicates that this DELETE is a subsequent DELETE to a failed CREATE 82 | // operation. 83 | if (event.RequestType === 'Delete' && event.PhysicalResourceId === CREATE_FAILED_PHYSICAL_ID_MARKER) { 84 | console.log('ignoring DELETE event caused by a failed CREATE event'); 85 | await respond('SUCCESS', event); 86 | return; 87 | } 88 | 89 | await stepFunctionsClient.send(new StartExecutionCommand({ 90 | stateMachineArn: process.env.STATE_MACHINE_ARN, 91 | input: JSON.stringify(event), 92 | })); 93 | } catch (err) { 94 | console.log(err); 95 | await respond('FAILED', { 96 | ...event, 97 | Reason: err instanceof Error ? err.message : undefined, 98 | }); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/static-website/README.md: -------------------------------------------------------------------------------- 1 | # StaticWebsite 2 | 3 | A CloudFront static website hosted on S3 with HTTPS redirect, SPA redirect, 4 | HTTP security headers and backend configuration saved to the bucket. 5 | 6 | ## Usage 7 | 8 | Define a `StaticWebsite`: 9 | 10 | ```ts 11 | import { CustomResource, Stack, StackProps } from 'aws-cdk-lib'; 12 | import * as deployment from 'aws-cdk-lib/aws-s3-deployment'; 13 | import * as cloudstructs from 'cloudstructs'; 14 | import { Construct } from 'constructs'; 15 | 16 | export class MyStack extends Stack { 17 | constructor(scope: Construct, id: string, props?: StackProps) { 18 | super(scope, id, props); 19 | 20 | const staticWebsite = new cloudstructs.StaticWebsite(this, 'StaticWebsite', { 21 | domainName: 'www.my-site.com', 22 | hostedZone: myHostedZone, 23 | backendConfiguration: { // Saved to `config.json` in the bucket 24 | stage: 'prod', 25 | apiUrl: 'https://www.my-api.com/api', 26 | }, 27 | }); 28 | 29 | // Use the website to add a deployment 30 | new deployment.BucketDeployment(this, 'Deployment', { 31 | destinationBucket: staticWebSite.bucket, 32 | sources: [deployment.Source.asset(sourcePath)], 33 | cacheControl: [deployment.CacheControl.fromString('public, max-age=31536000, immutable')], 34 | }); 35 | } 36 | } 37 | ``` 38 | 39 | The `backendConfiguration` will be saved as `config.json` in the S3 bucket of the 40 | static website. This allows the frontend to `fetch('/config.json')` to get its 41 | configuration. Deploy time values can be used: 42 | 43 | ```ts 44 | const myApi = new apigateway.LambdaRestApi(this, 'Api', { ... }); 45 | const myUserPool = new cognito.UserPool(this, 'UserPool'); 46 | 47 | const staticWebsite = new cloudstructs.StaticWebsite(this, 'StaticWebsite', { 48 | domainName: 'www.my-site.com', 49 | hostedZone: myHostedZone, 50 | backendConfiguration: { 51 | apiUrl: myApi.url, 52 | userPoolId: myUserPool.userPoolId, 53 | }, 54 | }); 55 | ``` 56 | 57 | By default a HTTPS redirect will be created from the domain name of the hosted 58 | zone to the domain name of the static website. This can be changed by specifying 59 | the `redirects` prop: 60 | 61 | ```ts 62 | const staticWebsite = new cloudstructs.StaticWebsite(this, 'StaticWebsite', { 63 | domainName: 'www.my-site.com', 64 | hostedZone: myHostedZone, 65 | redirects: ['my-site.com', 'hello.my-site.com'], 66 | }); 67 | ``` 68 | -------------------------------------------------------------------------------- /src/static-website/origin-request-function.ts: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | import * as path from 'path'; 3 | import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; 4 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 5 | import { Construct } from 'constructs'; 6 | 7 | /** 8 | * Props for OriginRequestFunction 9 | */ 10 | export interface OriginRequestFunctionProps extends cloudfront.experimental.EdgeFunctionProps { 11 | } 12 | 13 | /** 14 | * An AWS Lambda function which executes src/static-website/origin-request. 15 | */ 16 | export class OriginRequestFunction extends cloudfront.experimental.EdgeFunction { 17 | constructor(scope: Construct, id: string, props?: OriginRequestFunctionProps) { 18 | super(scope, id, { 19 | description: 'src/static-website/origin-request.edge-lambda.ts', 20 | ...props, 21 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS), 22 | handler: 'index.handler', 23 | code: lambda.Code.fromAsset(path.join(__dirname, '../../assets/static-website/origin-request.edge-lambda')), 24 | }); 25 | } 26 | } -------------------------------------------------------------------------------- /src/static-website/origin-request.edge-lambda.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | export async function handler(event: AWSLambda.CloudFrontRequestEvent): Promise { 4 | const request = event.Records[0].cf.request; 5 | 6 | if (!path.extname(request.uri)) request.uri = '/index.html'; 7 | 8 | return request; 9 | } 10 | -------------------------------------------------------------------------------- /src/toolkit-cleaner/README.md: -------------------------------------------------------------------------------- 1 | # ToolkitCleaner 2 | 3 | Clean unused S3 and ECR assets from your CDK Toolkit. 4 | 5 | ## Usage 6 | 7 | Define a `ToolkitCleaner`: 8 | 9 | ```ts 10 | import { Stack, StackProps } from 'aws-cdk-lib'; 11 | import { ToolkitCleaner } from 'cloudstructs/lib/toolkit-cleaner'; 12 | import { Construct } from 'constructs'; 13 | 14 | export class MyStack extends Stack { 15 | constructor(scope: Construct, id: string, props?: StackProps) { 16 | super(scope, id, props); 17 | 18 | new ToolkitCleaner(this, 'ToolkitCleaner'); 19 | } 20 | } 21 | ``` 22 | 23 | The `ToolkitCleaner` construct creates a state machine that runs every day 24 | and removes unused S3 and ECR assets from your CDK Toolkit. The state machine 25 | outputs the number of deleted assets and the total reclaimed size in bytes. 26 | 27 | The running frequency can be customized using the `schedule` prop. You can also 28 | choose to only run the Step Function manually by passing 29 | `scheduleEnabled: false`. 30 | 31 | By default all unused assets are removed. If you wish to retain assets that 32 | were created recently, specify the `retainAssetsNewerThan` prop: 33 | 34 | ```ts 35 | new ToolkitCleaner(this, 'ToolkitCleaner', { 36 | // Do not delete assets created in the last 30 days even if unused 37 | retainAssetsNewerThan: Duration.days(30), 38 | }); 39 | ``` 40 | 41 | Use the `dryRun` prop to only output the number of assets and total size that 42 | would be deleted but without actually deleting assets. 43 | 44 |

45 | 46 |

47 | -------------------------------------------------------------------------------- /src/toolkit-cleaner/clean-images-function.ts: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | import * as path from 'path'; 3 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 4 | import { Construct } from 'constructs'; 5 | 6 | /** 7 | * Props for CleanImagesFunction 8 | */ 9 | export interface CleanImagesFunctionProps extends lambda.FunctionOptions { 10 | } 11 | 12 | /** 13 | * An AWS Lambda function which executes src/toolkit-cleaner/clean-images. 14 | */ 15 | export class CleanImagesFunction extends lambda.Function { 16 | constructor(scope: Construct, id: string, props?: CleanImagesFunctionProps) { 17 | super(scope, id, { 18 | description: 'src/toolkit-cleaner/clean-images.lambda.ts', 19 | ...props, 20 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS), 21 | handler: 'index.handler', 22 | code: lambda.Code.fromAsset(path.join(__dirname, '../../assets/toolkit-cleaner/clean-images.lambda')), 23 | }); 24 | this.addEnvironment('AWS_NODEJS_CONNECTION_REUSE_ENABLED', '1', { removeInEdge: true }); 25 | } 26 | } -------------------------------------------------------------------------------- /src/toolkit-cleaner/clean-images.lambda.ts: -------------------------------------------------------------------------------- 1 | import { BatchDeleteImageCommand, DescribeImagesCommand, ECRClient } from '@aws-sdk/client-ecr'; 2 | 3 | const ecrClient = new ECRClient({}); 4 | 5 | export async function handler(assetHashes: string[]) { 6 | if (!process.env.REPOSITORY_NAME) { 7 | throw new Error('Missing REPOSITORY_NAME'); 8 | } 9 | 10 | let deleted = 0; 11 | let reclaimed = 0; 12 | 13 | let nextToken: string | undefined; 14 | let finished = false; 15 | while (!finished) { 16 | const response = await ecrClient.send(new DescribeImagesCommand({ 17 | repositoryName: process.env.REPOSITORY_NAME, 18 | nextToken, 19 | })); 20 | 21 | const toDelete = response.imageDetails?.filter(x => { 22 | if (!x.imageTags) { 23 | return false; 24 | } 25 | 26 | let pred = !assetHashes.includes(x.imageTags[0]); 27 | 28 | if (process.env.RETAIN_MILLISECONDS) { 29 | if (!x.imagePushedAt) { 30 | return false; 31 | } 32 | 33 | const limitDate = new Date(Date.now() - parseInt(process.env.RETAIN_MILLISECONDS)); 34 | pred = pred && x.imagePushedAt && x.imagePushedAt < limitDate; 35 | } 36 | 37 | return pred; 38 | }); 39 | 40 | if (toDelete && toDelete.length !== 0) { 41 | if (process.env.RUN) { 42 | await ecrClient.send(new BatchDeleteImageCommand({ 43 | repositoryName: process.env.REPOSITORY_NAME, 44 | imageIds: toDelete.map(x => ({ imageTag: x.imageTags![0] })), 45 | })); 46 | } 47 | deleted += toDelete.length; 48 | reclaimed += toDelete.reduce((acc, x) => acc + (x.imageSizeInBytes ?? 0), 0); 49 | } 50 | 51 | nextToken = response.nextToken; 52 | if (nextToken === undefined) { 53 | finished = true; 54 | } 55 | } 56 | 57 | return { 58 | Deleted: deleted, 59 | Reclaimed: reclaimed, 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /src/toolkit-cleaner/clean-objects-function.ts: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | import * as path from 'path'; 3 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 4 | import { Construct } from 'constructs'; 5 | 6 | /** 7 | * Props for CleanObjectsFunction 8 | */ 9 | export interface CleanObjectsFunctionProps extends lambda.FunctionOptions { 10 | } 11 | 12 | /** 13 | * An AWS Lambda function which executes src/toolkit-cleaner/clean-objects. 14 | */ 15 | export class CleanObjectsFunction extends lambda.Function { 16 | constructor(scope: Construct, id: string, props?: CleanObjectsFunctionProps) { 17 | super(scope, id, { 18 | description: 'src/toolkit-cleaner/clean-objects.lambda.ts', 19 | ...props, 20 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS), 21 | handler: 'index.handler', 22 | code: lambda.Code.fromAsset(path.join(__dirname, '../../assets/toolkit-cleaner/clean-objects.lambda')), 23 | }); 24 | this.addEnvironment('AWS_NODEJS_CONNECTION_REUSE_ENABLED', '1', { removeInEdge: true }); 25 | } 26 | } -------------------------------------------------------------------------------- /src/toolkit-cleaner/clean-objects.lambda.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { DeleteObjectsCommand, ListObjectVersionsCommand, S3Client } from '@aws-sdk/client-s3'; 3 | 4 | const s3Client = new S3Client({}); 5 | 6 | export async function handler(assetHashes: string[]) { 7 | if (!process.env.BUCKET_NAME) { 8 | throw new Error('Missing BUCKET_NAME'); 9 | } 10 | 11 | let deleted = 0; 12 | let reclaimed = 0; 13 | 14 | let nextKeyMarker: string | undefined; 15 | let finished = false; 16 | while (!finished) { 17 | const response = await s3Client.send(new ListObjectVersionsCommand({ 18 | Bucket: process.env.BUCKET_NAME, 19 | KeyMarker: nextKeyMarker, 20 | })); 21 | 22 | const toDelete = response.Versions?.filter(v => { 23 | if (!v.Key) { 24 | return false; 25 | } 26 | 27 | const hash = path.basename(v.Key, path.extname(v.Key)); 28 | let pred = !assetHashes.includes(hash); 29 | 30 | if (process.env.RETAIN_MILLISECONDS) { 31 | if (!v.LastModified) { 32 | return false; 33 | } 34 | 35 | const limitDate = new Date(Date.now() - parseInt(process.env.RETAIN_MILLISECONDS)); 36 | pred = pred && v.LastModified < limitDate; 37 | } 38 | 39 | return pred; 40 | }); 41 | 42 | if (toDelete && toDelete.length !== 0) { 43 | if (process.env.RUN) { 44 | await s3Client.send(new DeleteObjectsCommand({ 45 | Bucket: process.env.BUCKET_NAME, 46 | Delete: { 47 | Objects: toDelete.map(v => ({ Key: v.Key!, VersionId: v.VersionId })), 48 | }, 49 | })); 50 | } 51 | deleted += toDelete.length; 52 | reclaimed += toDelete.reduce((acc, x) => acc + (x.Size ?? 0), 0); 53 | } 54 | 55 | nextKeyMarker = response.NextKeyMarker; 56 | if (nextKeyMarker === undefined) { 57 | finished = true; 58 | } 59 | } 60 | 61 | return { 62 | Deleted: deleted, 63 | Reclaimed: reclaimed, 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /src/toolkit-cleaner/extract-template-hashes-function.ts: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | import * as path from 'path'; 3 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 4 | import { Construct } from 'constructs'; 5 | 6 | /** 7 | * Props for ExtractTemplateHashesFunction 8 | */ 9 | export interface ExtractTemplateHashesFunctionProps extends lambda.FunctionOptions { 10 | } 11 | 12 | /** 13 | * An AWS Lambda function which executes src/toolkit-cleaner/extract-template-hashes. 14 | */ 15 | export class ExtractTemplateHashesFunction extends lambda.Function { 16 | constructor(scope: Construct, id: string, props?: ExtractTemplateHashesFunctionProps) { 17 | super(scope, id, { 18 | description: 'src/toolkit-cleaner/extract-template-hashes.lambda.ts', 19 | ...props, 20 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS), 21 | handler: 'index.handler', 22 | code: lambda.Code.fromAsset(path.join(__dirname, '../../assets/toolkit-cleaner/extract-template-hashes.lambda')), 23 | }); 24 | this.addEnvironment('AWS_NODEJS_CONNECTION_REUSE_ENABLED', '1', { removeInEdge: true }); 25 | } 26 | } -------------------------------------------------------------------------------- /src/toolkit-cleaner/extract-template-hashes.lambda.ts: -------------------------------------------------------------------------------- 1 | import { CloudFormationClient, GetTemplateCommand } from '@aws-sdk/client-cloudformation'; 2 | 3 | const cloudFormationClient = new CloudFormationClient({}); 4 | 5 | export async function handler(stackName: string) { 6 | const template = await cloudFormationClient.send(new GetTemplateCommand({ 7 | StackName: stackName, 8 | })); 9 | 10 | if (!template.TemplateBody) { 11 | return []; 12 | } 13 | 14 | if (!process.env.DOCKER_IMAGE_ASSET_HASH) { 15 | throw new Error('Missing DOCKER_IMAGE_ASSET_HASH environment variable'); 16 | } 17 | const dockerTagPrefix = findDockerTagPrefix(process.env.DOCKER_IMAGE_ASSET_HASH); 18 | 19 | const regexp = new RegExp(`(${dockerTagPrefix})?[a-f0-9]{64}`, 'g'); 20 | const hashes = template.TemplateBody.match(regexp); 21 | 22 | return [...new Set(hashes)]; 23 | } 24 | 25 | function findDockerTagPrefix(hash: string): string { 26 | if (hash.length === 64) { 27 | return ''; 28 | } 29 | 30 | return hash.substring(0, hash.length - 64); 31 | } 32 | -------------------------------------------------------------------------------- /src/toolkit-cleaner/get-stack-names-function.ts: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | import * as path from 'path'; 3 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 4 | import { Construct } from 'constructs'; 5 | 6 | /** 7 | * Props for GetStackNamesFunction 8 | */ 9 | export interface GetStackNamesFunctionProps extends lambda.FunctionOptions { 10 | } 11 | 12 | /** 13 | * An AWS Lambda function which executes src/toolkit-cleaner/get-stack-names. 14 | */ 15 | export class GetStackNamesFunction extends lambda.Function { 16 | constructor(scope: Construct, id: string, props?: GetStackNamesFunctionProps) { 17 | super(scope, id, { 18 | description: 'src/toolkit-cleaner/get-stack-names.lambda.ts', 19 | ...props, 20 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS), 21 | handler: 'index.handler', 22 | code: lambda.Code.fromAsset(path.join(__dirname, '../../assets/toolkit-cleaner/get-stack-names.lambda')), 23 | }); 24 | this.addEnvironment('AWS_NODEJS_CONNECTION_REUSE_ENABLED', '1', { removeInEdge: true }); 25 | } 26 | } -------------------------------------------------------------------------------- /src/toolkit-cleaner/get-stack-names.lambda.ts: -------------------------------------------------------------------------------- 1 | import { CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; 2 | 3 | const cloudFormationClient = new CloudFormationClient({}); 4 | 5 | export async function handler() { 6 | const res: string[] = []; 7 | 8 | let nextToken: string | undefined; 9 | let finished = false; 10 | while (!finished) { 11 | const response = await cloudFormationClient.send(new DescribeStacksCommand({ NextToken: nextToken })); 12 | 13 | for (const stack of (response.Stacks ?? [])) { 14 | if (stack.StackName) { 15 | res.push(stack.StackName); 16 | } 17 | } 18 | 19 | nextToken = response.NextToken; 20 | if (nextToken === undefined) { 21 | finished = true; 22 | } 23 | } 24 | 25 | return res; 26 | } 27 | -------------------------------------------------------------------------------- /src/url-shortener/README.md: -------------------------------------------------------------------------------- 1 | # UrlShortener 2 | 3 | Deploy an URL shortener API. 4 | 5 | Uses a DynamoDB table to increment a counter. The value of the counter is base62 6 | encoded and then a zero-byte object with redirection is stored in S3. 7 | 8 | ## Usage 9 | 10 | Define a `UrlShortener`: 11 | 12 | ```ts 13 | import { Stack, StackProps } from 'aws-cdk-lib'; 14 | import * as route53 from 'aws-cdk-lib/aws-route53'; 15 | import * as cloudstructs from 'cloudstructs'; 16 | import { Construct } from 'constructs'; 17 | 18 | export class MyStack extends Stack { 19 | constructor(scope: Construct, id: string, props?: StackProps) { 20 | super(scope, id, props); 21 | 22 | // The hosted zone for the domain of the short urls 23 | const hostedZone = new route53.HostedZone(this, 'HostedZone', { zoneName: 'short.com' }); 24 | 25 | new cloudstructs.UrlShortener(this, 'UrlShortener', { hostedZone }); 26 | } 27 | } 28 | ``` 29 | 30 | The deployed API expects the following JSON body: 31 | 32 | ```json 33 | { 34 | "url": "https://www.mylongurl.com/very/long/path" 35 | } 36 | ``` 37 | 38 | and replies with: 39 | 40 | ```json 41 | { 42 | "url": "https://www.mylongurl.com/very/long/path", 43 | "shortUrl": "https://short.com/trBkV" 44 | } 45 | ``` 46 | 47 | By default, the API is public. It can be made private by specifying 48 | the `apiGatewayEndpoint` prop. 49 | 50 | An authorizer can be added to the API by specifying the `apiGatewayAuthorizer` 51 | prop. 52 | 53 | Enable CORS by passing the `corsAllowOrigins` prop. 54 | -------------------------------------------------------------------------------- /src/url-shortener/redirect-function.ts: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | import * as path from 'path'; 3 | import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; 4 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 5 | import { Construct } from 'constructs'; 6 | 7 | /** 8 | * Props for RedirectFunction 9 | */ 10 | export interface RedirectFunctionProps extends cloudfront.experimental.EdgeFunctionProps { 11 | } 12 | 13 | /** 14 | * An AWS Lambda function which executes src/url-shortener/redirect. 15 | */ 16 | export class RedirectFunction extends cloudfront.experimental.EdgeFunction { 17 | constructor(scope: Construct, id: string, props?: RedirectFunctionProps) { 18 | super(scope, id, { 19 | description: 'src/url-shortener/redirect.edge-lambda.ts', 20 | ...props, 21 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS), 22 | handler: 'index.handler', 23 | code: lambda.Code.fromAsset(path.join(__dirname, '../../assets/url-shortener/redirect.edge-lambda')), 24 | }); 25 | } 26 | } -------------------------------------------------------------------------------- /src/url-shortener/redirect.edge-lambda.ts: -------------------------------------------------------------------------------- 1 | import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; 2 | 3 | export async function handler(event: AWSLambda.CloudFrontRequestEvent): Promise { 4 | const request = event.Records[0].cf.request; 5 | 6 | try { 7 | const s3Origin = request.origin?.s3; 8 | 9 | if (!s3Origin) { 10 | throw new Error('No S3 origin'); 11 | } 12 | 13 | const s3Client = new S3Client({ region: s3Origin.region }); 14 | const bucket = s3Origin.domainName.replace(new RegExp(`.s3.${s3Origin.region}.amazonaws.com$`), ''); 15 | const key = request.uri.substring(1); // remove first slash 16 | 17 | const data = await s3Client.send(new GetObjectCommand({ 18 | Bucket: bucket, 19 | Key: key, 20 | })); 21 | 22 | if (!data.Body) { 23 | throw new Error('No body'); 24 | } 25 | 26 | const redirect = JSON.parse(await data.Body.transformToString()); 27 | 28 | return { 29 | status: '301', 30 | statusDescription: 'Moved Permanently', 31 | headers: { 32 | location: [{ 33 | key: 'Location', 34 | value: redirect.url, 35 | }], 36 | }, 37 | }; 38 | } catch (err) { 39 | console.log(err); 40 | 41 | return { 42 | status: '404', 43 | statusDescription: 'Not Found', 44 | }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/url-shortener/shortener-function.ts: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | import * as path from 'path'; 3 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 4 | import { Construct } from 'constructs'; 5 | 6 | /** 7 | * Props for ShortenerFunction 8 | */ 9 | export interface ShortenerFunctionProps extends lambda.FunctionOptions { 10 | } 11 | 12 | /** 13 | * An AWS Lambda function which executes src/url-shortener/shortener. 14 | */ 15 | export class ShortenerFunction extends lambda.Function { 16 | constructor(scope: Construct, id: string, props?: ShortenerFunctionProps) { 17 | super(scope, id, { 18 | description: 'src/url-shortener/shortener.lambda.ts', 19 | ...props, 20 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS), 21 | handler: 'index.handler', 22 | code: lambda.Code.fromAsset(path.join(__dirname, '../../assets/url-shortener/shortener.lambda')), 23 | }); 24 | this.addEnvironment('AWS_NODEJS_CONNECTION_REUSE_ENABLED', '1', { removeInEdge: true }); 25 | } 26 | } -------------------------------------------------------------------------------- /src/url-shortener/shortener.lambda.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { URL } from 'url'; 3 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 4 | import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; 5 | import { DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb'; 6 | 7 | function base62Encode(int: number): string { 8 | const characterSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 9 | if (int === 0) return '0'; 10 | let s = ''; 11 | while (int > 0) { 12 | s = characterSet[int % 62] + s; 13 | int = Math.floor(int / 62); 14 | } 15 | return s; 16 | }; 17 | 18 | function getEnv(name: string): string { 19 | const value = process.env[name]; 20 | 21 | if (!value) { 22 | throw new Error(`The environment variable ${name} is not defined`); 23 | } 24 | 25 | return value; 26 | } 27 | 28 | function isUrlValid(url?: string): boolean { 29 | if (!url) { 30 | return false; 31 | } 32 | try { 33 | new URL(url); 34 | return true; 35 | } catch (err) { 36 | return false; 37 | } 38 | } 39 | 40 | const dynamoDBClient = new DynamoDBClient({}); 41 | const documentClient = DynamoDBDocumentClient.from(dynamoDBClient); 42 | 43 | const s3Client = new S3Client({}); 44 | 45 | export async function handler(event: AWSLambda.APIGatewayProxyEvent): Promise { 46 | console.log('Event: %j', event); 47 | 48 | const response: AWSLambda.APIGatewayProxyResult = { 49 | statusCode: 201, 50 | body: '', 51 | headers: process.env.CORS_ALLOW_ORIGINS 52 | ? { 'Access-Control-Allow-Origin': process.env.CORS_ALLOW_ORIGINS } 53 | : undefined, 54 | }; 55 | 56 | try { 57 | const body = JSON.parse(event.body ?? '{}'); 58 | 59 | if (!isUrlValid(body.url)) { 60 | return { 61 | ...response, 62 | statusCode: 400, 63 | }; 64 | } 65 | 66 | // Get next counter value 67 | const update = await documentClient.send(new UpdateCommand({ 68 | TableName: getEnv('TABLE_NAME'), 69 | Key: { key: 'counter' }, 70 | UpdateExpression: 'ADD #value :incr', 71 | ExpressionAttributeNames: { '#value': 'value' }, 72 | ExpressionAttributeValues: { ':incr': 1 }, 73 | ReturnValues: 'UPDATED_NEW', 74 | })); 75 | 76 | const value = update.Attributes?.value; 77 | 78 | if (!value) { 79 | throw new Error('Cannot get next counter value'); 80 | } 81 | 82 | const key = base62Encode(value); 83 | console.log('Key: %j', key); 84 | 85 | const putObject = await s3Client.send(new PutObjectCommand({ 86 | Bucket: getEnv('BUCKET_NAME'), 87 | Key: key, 88 | ContentType: 'application/json', 89 | Body: JSON.stringify({ url: body.url }), 90 | })); 91 | console.log('Put object: %j', putObject); 92 | 93 | // Return short url 94 | return { 95 | ...response, 96 | body: JSON.stringify({ 97 | url: body.url, 98 | shortUrl: `https://${getEnv('DOMAIN_NAME')}/${key}`, 99 | }), 100 | }; 101 | } catch (err) { 102 | console.log(err); 103 | 104 | return { 105 | ...response, 106 | statusCode: 500, 107 | }; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /test/codecommit-mirror/codecommit-mirror.test.ts: -------------------------------------------------------------------------------- 1 | import { Duration, Stack } from 'aws-cdk-lib'; 2 | import { Template } from 'aws-cdk-lib/assertions'; 3 | import * as ecs from 'aws-cdk-lib/aws-ecs'; 4 | import * as events from 'aws-cdk-lib/aws-events'; 5 | import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; 6 | import { CodeCommitMirror, CodeCommitMirrorSourceRepository } from '../../src'; 7 | 8 | let stack: Stack; 9 | let cluster: ecs.ICluster; 10 | beforeEach(() => { 11 | stack = new Stack(); 12 | cluster = new ecs.Cluster(stack, 'Cluster'); 13 | }); 14 | 15 | test('CodeCommitMirror with a public GitHub repo', () => { 16 | new CodeCommitMirror(stack, 'Mirror', { 17 | repository: CodeCommitMirrorSourceRepository.gitHub('jogold', 'cloudstructs'), 18 | cluster, 19 | }); 20 | 21 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 22 | }); 23 | 24 | test('CodeCommitMirror with a private GitHub repo', () => { 25 | const urlSecret = secretsmanager.Secret.fromSecretNameV2(stack, 'Secret', 'clone-url'); 26 | 27 | new CodeCommitMirror(stack, 'Mirror', { 28 | cluster, 29 | repository: CodeCommitMirrorSourceRepository.private('private', ecs.Secret.fromSecretsManager(urlSecret)), 30 | schedule: events.Schedule.rate(Duration.hours(6)), 31 | }); 32 | 33 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 34 | }); 35 | -------------------------------------------------------------------------------- /test/dmarc/dmarc-reporter.test.ts: -------------------------------------------------------------------------------- 1 | import { Stack } from 'aws-cdk-lib'; 2 | import { Template } from 'aws-cdk-lib/assertions'; 3 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 4 | import * as route53 from 'aws-cdk-lib/aws-route53'; 5 | import * as ses from 'aws-cdk-lib/aws-ses'; 6 | import { DmarcAlignment, DmarcPolicy, DmarcReporter } from '../../src'; 7 | 8 | let stack: Stack; 9 | beforeEach(() => { 10 | stack = new Stack(); 11 | }); 12 | 13 | test('DmarcReporter', () => { 14 | const fn = new lambda.Function(stack, 'Fn', { 15 | code: lambda.Code.fromInline('export.handler = () => void;'), 16 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS, { 17 | supportsInlineCode: true, 18 | }), 19 | handler: 'index.handler', 20 | }); 21 | const ruleSet = ses.ReceiptRuleSet.fromReceiptRuleSetName( 22 | stack, 23 | 'RuleSet', 24 | 'rule-set', 25 | ); 26 | const hostedZone = new route53.HostedZone(stack, 'HostedZone', { 27 | zoneName: 'example.com', 28 | }); 29 | new DmarcReporter(stack, 'DmarcReporter', { 30 | hostedZone, 31 | dmarcPolicy: DmarcPolicy.REJECT, 32 | dmarcDkimAlignment: DmarcAlignment.RELAXED, 33 | dmarcSpfAlignment: DmarcAlignment.STRICT, 34 | dmarcPercentage: 55, 35 | dmarcSubdomainPolicy: DmarcPolicy.QUARANTINE, 36 | additionalEmailAddresses: ['someaddress@dmarc-service.com', 'otheraddress@other-service.com'], 37 | function: fn, 38 | receiptRuleSet: ruleSet, 39 | }); 40 | 41 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 42 | }); 43 | -------------------------------------------------------------------------------- /test/ecs-service-roller/ecs-service-roller.test.ts: -------------------------------------------------------------------------------- 1 | import { Duration, Stack } from 'aws-cdk-lib'; 2 | import { Template } from 'aws-cdk-lib/assertions'; 3 | import * as ecs from 'aws-cdk-lib/aws-ecs'; 4 | import * as events from 'aws-cdk-lib/aws-events'; 5 | import { EcsServiceRoller, RollTrigger } from '../../src'; 6 | 7 | let stack: Stack; 8 | let cluster: ecs.ICluster; 9 | let service: ecs.IService; 10 | beforeEach(() => { 11 | stack = new Stack(); 12 | cluster = new ecs.Cluster(stack, 'Cluster'); 13 | const taskDefinition = new ecs.FargateTaskDefinition(stack, 'TaskDef'); 14 | taskDefinition.addContainer('Container', { 15 | image: ecs.ContainerImage.fromRegistry('my-image'), 16 | }); 17 | service = new ecs.FargateService(stack, 'Service', { 18 | cluster, 19 | taskDefinition, 20 | }); 21 | }); 22 | 23 | test('EcsServiceRoller with default', () => { 24 | new EcsServiceRoller(stack, 'EcsServiceRoller', { 25 | cluster, 26 | service, 27 | }); 28 | 29 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 30 | }); 31 | 32 | test('EcsServiceRoller with schedule', () => { 33 | new EcsServiceRoller(stack, 'EcsServiceRoller', { 34 | cluster, 35 | service, 36 | trigger: RollTrigger.fromSchedule(events.Schedule.rate(Duration.hours(5))), 37 | }); 38 | 39 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 40 | }); 41 | 42 | test('EcsServiceRoller with rule', () => { 43 | const rule = new events.Rule(stack, 'Rule', { 44 | eventPattern: { 45 | detail: ['detail'], 46 | }, 47 | }); 48 | 49 | new EcsServiceRoller(stack, 'EcsServiceRoller', { 50 | cluster, 51 | service, 52 | trigger: RollTrigger.fromRule(rule), 53 | }); 54 | 55 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 56 | }); 57 | -------------------------------------------------------------------------------- /test/email-receiver/email-receiver.test.ts: -------------------------------------------------------------------------------- 1 | import { Stack } from 'aws-cdk-lib'; 2 | import { Template } from 'aws-cdk-lib/assertions'; 3 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 4 | import * as ses from 'aws-cdk-lib/aws-ses'; 5 | import { EmailReceiver } from '../../src'; 6 | 7 | let stack: Stack; 8 | beforeEach(() => { 9 | stack = new Stack(); 10 | }); 11 | 12 | test('EmailReceiver', () => { 13 | const fn = new lambda.Function(stack, 'Fn', { 14 | code: lambda.Code.fromInline('export.handler = () => void;'), 15 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS, { supportsInlineCode: true }), 16 | handler: 'index.handler', 17 | }); 18 | const ruleSet = ses.ReceiptRuleSet.fromReceiptRuleSetName(stack, 'RuleSet', 'rule-set'); 19 | new EmailReceiver(stack, 'EmailReceiver', { 20 | recipients: ['support@cloudstructs.com'], 21 | sourceWhitelist: '@amazon.com$', 22 | function: fn, 23 | receiptRuleSet: ruleSet, 24 | }); 25 | 26 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 27 | }); 28 | -------------------------------------------------------------------------------- /test/email-receiver/whitelist-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { handler } from '../../src/email-receiver/whitelist.lambda'; 2 | 3 | const sesEvent: AWSLambda.SESEvent = { 4 | Records: [ 5 | { 6 | eventSource: 'ses', 7 | eventVersion: '1.0', 8 | ses: { 9 | mail: { 10 | commonHeaders: { 11 | date: 'date', 12 | messageId: 'id', 13 | returnPath: 'path', 14 | }, 15 | destination: ['a', 'b'], 16 | headers: [], 17 | headersTruncated: false, 18 | messageId: 'id', 19 | source: 'info@help.com', 20 | timestamp: 'timestamp', 21 | }, 22 | receipt: { 23 | action: { 24 | type: 'Lambda', 25 | functionArn: 'arn', 26 | invocationType: 'RequestResponse', 27 | }, 28 | timestamp: 'timestamp', 29 | dkimVerdict: { 30 | status: 'PASS', 31 | }, 32 | dmarcVerdict: { 33 | status: 'PASS', 34 | }, 35 | processingTimeMillis: 123, 36 | recipients: [ 37 | 'hello@abc.com', 38 | ], 39 | spamVerdict: { 40 | status: 'PASS', 41 | }, 42 | spfVerdict: { 43 | status: 'PASS', 44 | }, 45 | virusVerdict: { 46 | status: 'PASS', 47 | }, 48 | dmarcPolicy: 'none', 49 | }, 50 | }, 51 | }, 52 | ], 53 | }; 54 | 55 | test('stops messages that do not sastify the whitelist', async () => { 56 | process.env.SOURCE_WHITELIST = '@constructs.com$'; 57 | 58 | const response = await handler(sesEvent); 59 | 60 | expect(response).toEqual({ disposition: 'STOP_RULE' }); 61 | }); 62 | 63 | test('processes messages that satisfies the whitelist', async () => { 64 | process.env.SOURCE_WHITELIST = '@constructs.com$'; 65 | 66 | const response = await handler({ 67 | Records: [ 68 | { 69 | ...sesEvent.Records[0], 70 | ses: { 71 | ...sesEvent.Records[0].ses, 72 | mail: { 73 | ...sesEvent.Records[0].ses.mail, 74 | source: 'hello@constructs.com', 75 | }, 76 | }, 77 | }, 78 | ], 79 | }); 80 | 81 | expect(response).toEqual({ disposition: 'CONTINUE' }); 82 | }); 83 | 84 | test('stops messages when SOURCE_WHITELIST is not defined', async () => { 85 | delete process.env.SOURCE_WHITELIST; 86 | 87 | const response = await handler(sesEvent); 88 | 89 | expect(response).toEqual({ disposition: 'STOP_RULE' }); 90 | }); 91 | -------------------------------------------------------------------------------- /test/mjml-template/mjml-template.integ.snapshot/mjml-template-integ.assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "36.0.0", 3 | "files": { 4 | "d7f7155645ae726810e2665bd0e7da31e1df3233c5b0a7bd9cc4c2a740284667": { 5 | "source": { 6 | "path": "mjml-template-integ.template.json", 7 | "packaging": "file" 8 | }, 9 | "destinations": { 10 | "current_account-current_region": { 11 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", 12 | "objectKey": "d7f7155645ae726810e2665bd0e7da31e1df3233c5b0a7bd9cc4c2a740284667.json", 13 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" 14 | } 15 | } 16 | } 17 | }, 18 | "dockerImages": {} 19 | } -------------------------------------------------------------------------------- /test/mjml-template/mjml-template.integ.snapshot/mjml-template-integ.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "Resources": { 3 | "Template576A9730": { 4 | "Type": "AWS::SES::Template", 5 | "Properties": { 6 | "Template": { 7 | "HtmlPart": "\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n Hello {{name}}
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n ", 8 | "SubjectPart": "Welcome!" 9 | } 10 | } 11 | } 12 | }, 13 | "Outputs": { 14 | "TemplateName": { 15 | "Value": { 16 | "Fn::GetAtt": [ 17 | "Template576A9730", 18 | "Id" 19 | ] 20 | } 21 | } 22 | }, 23 | "Parameters": { 24 | "BootstrapVersion": { 25 | "Type": "AWS::SSM::Parameter::Value", 26 | "Default": "/cdk-bootstrap/hnb659fds/version", 27 | "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" 28 | } 29 | }, 30 | "Rules": { 31 | "CheckBootstrapVersion": { 32 | "Assertions": [ 33 | { 34 | "Assert": { 35 | "Fn::Not": [ 36 | { 37 | "Fn::Contains": [ 38 | [ 39 | "1", 40 | "2", 41 | "3", 42 | "4", 43 | "5" 44 | ], 45 | { 46 | "Ref": "BootstrapVersion" 47 | } 48 | ] 49 | } 50 | ] 51 | }, 52 | "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." 53 | } 54 | ] 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /test/mjml-template/mjml-template.integ.ts: -------------------------------------------------------------------------------- 1 | import { App, CfnOutput, Stack, StackProps } from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import { MjmlTemplate } from '../../src'; 4 | 5 | class TestStack extends Stack { 6 | constructor(scope: Construct, id: string, props?: StackProps) { 7 | super(scope, id, props); 8 | 9 | const mjml = ` 10 | 11 | 12 | 13 | 14 | Hello {{name}} 15 | 16 | 17 | 18 | 19 | `; 20 | 21 | const template = new MjmlTemplate(this, 'Template', { 22 | subject: 'Welcome!', 23 | mjml, 24 | }); 25 | 26 | new CfnOutput(this, 'TemplateName', { 27 | value: template.templateName, 28 | }); 29 | } 30 | } 31 | 32 | const app = new App(); 33 | new TestStack(app, 'mjml-template-integ'); 34 | app.synth(); 35 | -------------------------------------------------------------------------------- /test/mjml-template/mjml-template.test.ts: -------------------------------------------------------------------------------- 1 | import { Stack } from 'aws-cdk-lib'; 2 | import { Template } from 'aws-cdk-lib/assertions'; 3 | import { MjmlTemplate } from '../../src'; 4 | 5 | let stack: Stack; 6 | beforeEach(() => { 7 | stack = new Stack(); 8 | }); 9 | 10 | test('MjmlTemplate', () => { 11 | const mjml = ` 12 | 13 | 14 | 15 | 16 | Hello {{name}} 17 | 18 | 19 | 20 | 21 | `; 22 | 23 | new MjmlTemplate(stack, 'Template', { 24 | subject: 'Welcome!', 25 | mjml, 26 | }); 27 | 28 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 29 | }); 30 | -------------------------------------------------------------------------------- /test/saml-identity-provider/saml-identity-provider.test.ts: -------------------------------------------------------------------------------- 1 | import { Stack } from 'aws-cdk-lib'; 2 | import { Template } from 'aws-cdk-lib/assertions'; 3 | import * as iam from 'aws-cdk-lib/aws-iam'; 4 | import { SamlFederatedPrincipal, SamlIdentityProvider } from '../../src'; 5 | 6 | let stack: Stack; 7 | beforeEach(() => { 8 | stack = new Stack(); 9 | }); 10 | 11 | test('SamlIdentityProvider', () => { 12 | const identityProvider = new SamlIdentityProvider(stack, 'IdentityProvider', { 13 | metadataDocument: '', 14 | }); 15 | 16 | new iam.Role(stack, 'Role', { 17 | assumedBy: new SamlFederatedPrincipal(identityProvider), 18 | }); 19 | 20 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 21 | }); 22 | -------------------------------------------------------------------------------- /test/slack-app/slack-app.integ.snapshot/slack-app-integ.assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "36.0.0", 3 | "files": { 4 | "59f59ca19e9faca11be71294be3d1d8f1fea8aa131e332c4ba2dc44a0c40d655": { 5 | "source": { 6 | "path": "asset.59f59ca19e9faca11be71294be3d1d8f1fea8aa131e332c4ba2dc44a0c40d655.lambda", 7 | "packaging": "zip" 8 | }, 9 | "destinations": { 10 | "current_account-current_region": { 11 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", 12 | "objectKey": "59f59ca19e9faca11be71294be3d1d8f1fea8aa131e332c4ba2dc44a0c40d655.zip", 13 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" 14 | } 15 | } 16 | }, 17 | "4e26bf2d0a26f2097fb2b261f22bb51e3f6b4b52635777b1e54edbd8e2d58c35": { 18 | "source": { 19 | "path": "asset.4e26bf2d0a26f2097fb2b261f22bb51e3f6b4b52635777b1e54edbd8e2d58c35", 20 | "packaging": "zip" 21 | }, 22 | "destinations": { 23 | "current_account-current_region": { 24 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", 25 | "objectKey": "4e26bf2d0a26f2097fb2b261f22bb51e3f6b4b52635777b1e54edbd8e2d58c35.zip", 26 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" 27 | } 28 | } 29 | }, 30 | "3542be390685e0c8353d92ccb5796d343cd93ca946b6b0de798004206a199adc": { 31 | "source": { 32 | "path": "asset.3542be390685e0c8353d92ccb5796d343cd93ca946b6b0de798004206a199adc", 33 | "packaging": "zip" 34 | }, 35 | "destinations": { 36 | "current_account-current_region": { 37 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", 38 | "objectKey": "3542be390685e0c8353d92ccb5796d343cd93ca946b6b0de798004206a199adc.zip", 39 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" 40 | } 41 | } 42 | }, 43 | "2941e62644a56eaaca06c8bcba11183f4d1260f772926cc48084a24e2cc54105": { 44 | "source": { 45 | "path": "slack-app-integ.template.json", 46 | "packaging": "file" 47 | }, 48 | "destinations": { 49 | "current_account-current_region": { 50 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", 51 | "objectKey": "2941e62644a56eaaca06c8bcba11183f4d1260f772926cc48084a24e2cc54105.json", 52 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" 53 | } 54 | } 55 | } 56 | }, 57 | "dockerImages": {} 58 | } -------------------------------------------------------------------------------- /test/slack-app/slack-app.integ.ts: -------------------------------------------------------------------------------- 1 | import { App, CfnOutput, Stack, StackProps } from 'aws-cdk-lib'; 2 | import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; 3 | import { Construct } from 'constructs'; 4 | import { SlackApp, SlackAppManifestDefinition } from '../../src'; 5 | 6 | class TestStack extends Stack { 7 | constructor(scope: Construct, id: string, props?: StackProps) { 8 | super(scope, id, props); 9 | 10 | const app = new SlackApp(this, 'MyApp', { 11 | configurationTokenSecret: secretsmanager.Secret.fromSecretNameV2(this, 'Secret', 'slack-app-config-token'), 12 | manifest: SlackAppManifestDefinition.fromManifest({ 13 | name: 'My App', 14 | description: 'A very cool Slack App deployed with CDK', 15 | }), 16 | }); 17 | 18 | new CfnOutput(this, 'AppId', { 19 | value: app.appId, 20 | }); 21 | } 22 | } 23 | 24 | const app = new App(); 25 | new TestStack(app, 'slack-app-integ'); 26 | app.synth(); 27 | -------------------------------------------------------------------------------- /test/slack-app/slack-app.test.ts: -------------------------------------------------------------------------------- 1 | import { Stack } from 'aws-cdk-lib'; 2 | import { Template } from 'aws-cdk-lib/assertions'; 3 | import * as secrets from 'aws-cdk-lib/aws-secretsmanager'; 4 | import { SlackApp, SlackAppManifestDefinition } from '../../src'; 5 | 6 | let stack: Stack; 7 | beforeEach(() => { 8 | stack = new Stack(); 9 | }); 10 | 11 | test('SlackApp', () => { 12 | new SlackApp(stack, 'MyApp', { 13 | configurationTokenSecret: secrets.Secret.fromSecretNameV2(stack, 'SlackSecret', 'slack-secret'), 14 | manifest: SlackAppManifestDefinition.fromManifest({ name: 'My App' }), 15 | }); 16 | 17 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 18 | }); 19 | -------------------------------------------------------------------------------- /test/slack-events/handler.test.ts: -------------------------------------------------------------------------------- 1 | import 'aws-sdk-client-mock-jest'; 2 | import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge'; 3 | import { mockClient } from 'aws-sdk-client-mock'; 4 | import { handler } from '../../src/slack-events/events.lambda'; 5 | import * as signature from '../../src/slack-events/signature'; 6 | 7 | process.env.SLACK_SIGNING_SECRET = 'secret'; 8 | 9 | const eventBridgeClientMock = mockClient(EventBridgeClient); 10 | 11 | beforeEach(() => { 12 | eventBridgeClientMock.reset(); 13 | eventBridgeClientMock.on(PutEventsCommand).resolves({}); 14 | }); 15 | 16 | test('returns 403 on invalid signature', async () => { 17 | jest.spyOn(signature, 'verifyRequestSignature').mockReturnValueOnce(false); 18 | 19 | const response = await handler({ 20 | body: '{}', 21 | headers: { 22 | 'X-Slack-Signature': 'signature', 23 | 'X-Slack-Request-Timestamp': '1000', 24 | }, 25 | } as unknown as AWSLambda.APIGatewayProxyEvent); 26 | 27 | expect(response).toEqual({ 28 | body: '', 29 | statusCode: 403, 30 | }); 31 | }); 32 | 33 | test('url verification', async () => { 34 | jest.spyOn(signature, 'verifyRequestSignature').mockReturnValueOnce(true); 35 | 36 | const response = await handler({ 37 | body: JSON.stringify({ 38 | type: 'url_verification', 39 | challenge: 'challenge', 40 | }), 41 | headers: { 42 | 'X-Slack-Signature': 'signature', 43 | 'X-Slack-Request-Timestamp': '1000', 44 | }, 45 | } as unknown as AWSLambda.APIGatewayProxyEvent); 46 | 47 | expect(response).toEqual({ 48 | body: JSON.stringify({ challenge: 'challenge' }), 49 | statusCode: 200, 50 | }); 51 | }); 52 | 53 | test('puts events', async () => { 54 | jest.spyOn(signature, 'verifyRequestSignature').mockReturnValueOnce(true); 55 | 56 | const body = JSON.stringify({ 57 | type: 'event', 58 | api_app_id: 'app-id', 59 | event_time: '2020-12-01T12:00:00.000Z', 60 | }); 61 | 62 | const response = await handler({ 63 | body, 64 | headers: { 65 | 'X-Slack-Signature': 'signature', 66 | 'X-Slack-Request-Timestamp': '1000', 67 | }, 68 | } as unknown as AWSLambda.APIGatewayProxyEvent); 69 | 70 | expect(eventBridgeClientMock).toHaveReceivedCommandWith(PutEventsCommand, { 71 | Entries: [{ 72 | Detail: body, 73 | DetailType: 'Slack Event', 74 | Source: 'slack', 75 | Resources: ['app-id'], 76 | EventBusName: undefined, 77 | Time: new Date('2020-12-01T12:00:00.000Z'), 78 | }], 79 | }); 80 | 81 | expect(response).toEqual({ 82 | body: '', 83 | statusCode: 200, 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /test/slack-events/signature.test.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import { verifyRequestSignature } from '../../src/slack-events/signature'; 3 | 4 | console.error = jest.fn(); 5 | 6 | test('with valid signature', () => { 7 | expect(verifyRequestSignature({ 8 | body: 'hello', 9 | requestSignature: 'v0=495018ea2506b8ab02682a3aa5237aa029f37f0589cf7bb6309a35178975e18c', 10 | requestTimestamp: 9999999999, 11 | signingSecret: 'secret', 12 | })).toBe(true); 13 | }); 14 | 15 | test('invalid signature', () => { 16 | expect(verifyRequestSignature({ 17 | body: 'hello', 18 | requestSignature: 'v0=invalid', 19 | requestTimestamp: 9999999999, 20 | signingSecret: 'secret', 21 | })).toBe(false); 22 | }); 23 | 24 | test('outdated signature', () => { 25 | const createHmacSpy = jest.spyOn(crypto, 'createHmac'); 26 | 27 | expect(verifyRequestSignature({ 28 | body: 'hello', 29 | requestSignature: 'v0=495018ea2506b8ab02682a3aa5237aa029f37f0589cf7bb6309a35178975e18c', 30 | requestTimestamp: 100, 31 | signingSecret: 'secret', 32 | })).toBe(false); 33 | 34 | expect(createHmacSpy).not.toHaveBeenCalled(); 35 | 36 | createHmacSpy.mockRestore(); 37 | }); 38 | -------------------------------------------------------------------------------- /test/slack-events/slack-events.test.ts: -------------------------------------------------------------------------------- 1 | import { SecretValue, Stack } from 'aws-cdk-lib'; 2 | import { Template } from 'aws-cdk-lib/assertions'; 3 | import { SlackEvents } from '../../src'; 4 | 5 | let stack: Stack; 6 | beforeEach(() => { 7 | stack = new Stack(); 8 | }); 9 | 10 | test('SlackEvents', () => { 11 | new SlackEvents(stack, 'SlackEvents', { 12 | signingSecret: SecretValue.secretsManager('my-slack-app'), 13 | }); 14 | 15 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 16 | }); 17 | -------------------------------------------------------------------------------- /test/slack-textract/handler.test.ts: -------------------------------------------------------------------------------- 1 | import 'aws-sdk-client-mock-jest'; 2 | import { DetectDocumentTextCommand, TextractClient } from '@aws-sdk/client-textract'; 3 | import { FilesInfoResponse, WebClient } from '@slack/web-api'; 4 | import { mockClient } from 'aws-sdk-client-mock'; 5 | import got from 'got'; 6 | import { handler } from '../../src/slack-textract/detect.lambda'; 7 | 8 | process.env.SLACK_TOKEN = 'token'; 9 | 10 | jest.mock('@slack/web-api'); 11 | jest.mock('got'); 12 | 13 | const gotMock = got as unknown as jest.Mock; 14 | const textractClientMock = mockClient(TextractClient); 15 | 16 | beforeEach(() => { 17 | textractClientMock.reset(); 18 | }); 19 | 20 | test('handler', async () => { 21 | const fileInfo: FilesInfoResponse = { 22 | ok: true, 23 | file: { 24 | mimetype: 'image/jpg', 25 | filetype: 'image', 26 | url_private: 'url-private', 27 | shares: { 28 | public: { 29 | C12345XYZ: [{ 30 | ts: 'ts', 31 | }], 32 | }, 33 | }, 34 | }, 35 | }; 36 | const filesInfoMock = jest.fn().mockResolvedValue(fileInfo); 37 | const postMessageMock = jest.fn().mockResolvedValue({ ok: true }); 38 | (WebClient as unknown as jest.Mock).mockImplementation(() => { 39 | return { 40 | files: { info: filesInfoMock }, 41 | chat: { postMessage: postMessageMock }, 42 | }; 43 | }); 44 | 45 | gotMock.mockImplementation(() => { 46 | return { buffer: () => Buffer.from('image-buffer') }; 47 | }); 48 | 49 | textractClientMock.on(DetectDocumentTextCommand).resolves({ 50 | Blocks: [ 51 | { 52 | BlockType: 'LINE', 53 | Text: 'Hello', 54 | }, 55 | { 56 | BlockType: 'CELL', 57 | Text: 'Not', 58 | }, 59 | { 60 | BlockType: 'LINE', 61 | Text: 'World!', 62 | }, 63 | ], 64 | }); 65 | 66 | await handler({ 67 | channel_id: 'C12345XYZ', 68 | file_id: 'F1234567XYZ', 69 | }); 70 | 71 | expect(filesInfoMock).toHaveBeenCalledWith({ 72 | file: 'F1234567XYZ', 73 | }); 74 | 75 | expect(gotMock).toHaveBeenCalledWith('url-private', { 76 | headers: { 77 | Authorization: 'Bearer token', 78 | }, 79 | }); 80 | 81 | expect(textractClientMock).toHaveReceivedCommandWith(DetectDocumentTextCommand, { 82 | Document: { Bytes: Buffer.from('image-buffer') }, 83 | }); 84 | 85 | expect(postMessageMock).toHaveBeenLastCalledWith({ 86 | channel: 'C12345XYZ', 87 | text: 'Hello\nWorld!', 88 | thread_ts: 'ts', 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /test/slack-textract/slack-textract.test.ts: -------------------------------------------------------------------------------- 1 | import { SecretValue, Stack } from 'aws-cdk-lib'; 2 | import { Template } from 'aws-cdk-lib/assertions'; 3 | import { SlackTextract } from '../../src'; 4 | 5 | let stack: Stack; 6 | beforeEach(() => { 7 | stack = new Stack(); 8 | }); 9 | 10 | test('SlackEvents', () => { 11 | new SlackTextract(stack, 'SlackTextract', { 12 | signingSecret: SecretValue.secretsManager('my-slack-app', { jsonField: 'signingSecret' }), 13 | appId: SecretValue.secretsManager('my-slack-app', { jsonField: 'appId' }).toString(), 14 | botToken: SecretValue.secretsManager('my-slack-app', { jsonField: 'botToken' }), 15 | }); 16 | 17 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 18 | }); 19 | -------------------------------------------------------------------------------- /test/ssl-server-test/analyze.lambda.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock'; 2 | import { handler } from '../../src/ssl-server-test/analyze.lambda'; 3 | 4 | test('calls SSL Labs API', async () => { 5 | const scope = nock('https://api.ssllabs.com/api/v3') 6 | .get('/analyze') 7 | .query({ key: 'value' }) 8 | .reply(200, { response: 'body' }); 9 | 10 | const response = await handler({ key: 'value' }); 11 | expect(scope.isDone()).toBeTruthy(); 12 | expect(response).toEqual({ response: 'body' }); 13 | }); -------------------------------------------------------------------------------- /test/ssl-server-test/extract-grade.lambda.test.ts: -------------------------------------------------------------------------------- 1 | import { handler } from '../../src/ssl-server-test/extract-grade.lambda'; 2 | 3 | test('extracts minimum grade', async () => { 4 | const grade = await handler({ 5 | endpoints: [ 6 | { grade: 'E' }, 7 | { grade: 'A+' }, 8 | { grade: 'C' }, 9 | ], 10 | }); 11 | 12 | expect(grade).toBe('E'); 13 | }); -------------------------------------------------------------------------------- /test/ssl-server-test/ssl-server-test.integ.snapshot/ssl-server-test-integ.assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "36.0.0", 3 | "files": { 4 | "99cbc6ab0ba088139797edc3a93d676604d7901bb439868609a1b41bbf8b5737": { 5 | "source": { 6 | "path": "asset.99cbc6ab0ba088139797edc3a93d676604d7901bb439868609a1b41bbf8b5737.lambda", 7 | "packaging": "zip" 8 | }, 9 | "destinations": { 10 | "current_account-current_region": { 11 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", 12 | "objectKey": "99cbc6ab0ba088139797edc3a93d676604d7901bb439868609a1b41bbf8b5737.zip", 13 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" 14 | } 15 | } 16 | }, 17 | "f2e5ee15de15486b62462c70e1375be9f3f090ada04d2ffaa2be626baa22e8e7": { 18 | "source": { 19 | "path": "asset.f2e5ee15de15486b62462c70e1375be9f3f090ada04d2ffaa2be626baa22e8e7.lambda", 20 | "packaging": "zip" 21 | }, 22 | "destinations": { 23 | "current_account-current_region": { 24 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", 25 | "objectKey": "f2e5ee15de15486b62462c70e1375be9f3f090ada04d2ffaa2be626baa22e8e7.zip", 26 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" 27 | } 28 | } 29 | }, 30 | "506bde221b1b877e07d1d049e7c3fb86cf38616003d1a094bf2be8df015facb8": { 31 | "source": { 32 | "path": "ssl-server-test-integ.template.json", 33 | "packaging": "file" 34 | }, 35 | "destinations": { 36 | "current_account-current_region": { 37 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", 38 | "objectKey": "506bde221b1b877e07d1d049e7c3fb86cf38616003d1a094bf2be8df015facb8.json", 39 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" 40 | } 41 | } 42 | } 43 | }, 44 | "dockerImages": {} 45 | } -------------------------------------------------------------------------------- /test/ssl-server-test/ssl-server-test.integ.ts: -------------------------------------------------------------------------------- 1 | import { App, Stack, StackProps } from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import { SslServerTest } from '../../src'; 4 | 5 | class TestStack extends Stack { 6 | constructor(scope: Construct, id: string, props?: StackProps) { 7 | super(scope, id, props); 8 | 9 | new SslServerTest(this, 'SslServerTestCdkDev', { 10 | host: 'cdk.dev', 11 | }); 12 | 13 | new SslServerTest(this, 'SslServerTestWwwCdkDev', { 14 | host: 'www.cdk.dev', 15 | }); 16 | } 17 | } 18 | 19 | const app = new App(); 20 | new TestStack(app, 'ssl-server-test-integ'); 21 | app.synth(); 22 | -------------------------------------------------------------------------------- /test/ssl-server-test/ssl-server-test.test.ts: -------------------------------------------------------------------------------- 1 | import { Stack } from 'aws-cdk-lib'; 2 | import { Template } from 'aws-cdk-lib/assertions'; 3 | import { SslServerTest } from '../../src'; 4 | 5 | let stack: Stack; 6 | beforeEach(() => { 7 | stack = new Stack(); 8 | }); 9 | 10 | test('SslServerTest', () => { 11 | new SslServerTest(stack, 'SslServerTest', { 12 | host: 'host', 13 | }); 14 | 15 | Template.fromStack(stack).hasResourceProperties('AWS::Events::Rule', { 16 | ScheduleExpression: 'rate(1 day)', 17 | Targets: [ 18 | { 19 | Arn: { 20 | Ref: 'cloudstructssslservertestStateMachine4198892D', 21 | }, 22 | Id: 'Target0', 23 | Input: { 24 | 'Fn::Join': [ 25 | '', 26 | [ 27 | '{"host":"host","minimumGrade":"A+","alarmTopicArn":"', 28 | { 29 | Ref: 'SslServerTestAlarmTopicE0FFB32B', 30 | }, 31 | '"}', 32 | ], 33 | ], 34 | }, 35 | RoleArn: { 36 | 'Fn::GetAtt': [ 37 | 'cloudstructssslservertestStateMachineEventsRole42C7B797', 38 | 'Arn', 39 | ], 40 | }, 41 | }, 42 | ], 43 | }); 44 | 45 | Template.fromStack(stack).hasResourceProperties('AWS::SNS::Topic', {}); 46 | 47 | Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { 48 | PolicyDocument: { 49 | Statement: [ 50 | { 51 | Action: 'lambda:InvokeFunction', 52 | Effect: 'Allow', 53 | Resource: [ 54 | { 55 | 'Fn::GetAtt': [ 56 | 'cloudstructssslservertestStateMachineAnalyzeFunction5F4E0EC3', 57 | 'Arn', 58 | ], 59 | }, 60 | { 61 | 'Fn::Join': [ 62 | '', 63 | [ 64 | { 65 | 'Fn::GetAtt': [ 66 | 'cloudstructssslservertestStateMachineAnalyzeFunction5F4E0EC3', 67 | 'Arn', 68 | ], 69 | }, 70 | ':*', 71 | ], 72 | ], 73 | }, 74 | ], 75 | }, 76 | { 77 | Action: 'lambda:InvokeFunction', 78 | Effect: 'Allow', 79 | Resource: [ 80 | { 81 | 'Fn::GetAtt': [ 82 | 'cloudstructssslservertestStateMachineExtractGradeFunction1D1F524D', 83 | 'Arn', 84 | ], 85 | }, 86 | { 87 | 'Fn::Join': [ 88 | '', 89 | [ 90 | { 91 | 'Fn::GetAtt': [ 92 | 'cloudstructssslservertestStateMachineExtractGradeFunction1D1F524D', 93 | 'Arn', 94 | ], 95 | }, 96 | ':*', 97 | ], 98 | ], 99 | }, 100 | ], 101 | }, 102 | { 103 | Action: 'sns:Publish', 104 | Effect: 'Allow', 105 | Resource: { 106 | Ref: 'SslServerTestAlarmTopicE0FFB32B', 107 | }, 108 | }, 109 | ], 110 | Version: '2012-10-17', 111 | }, 112 | }); 113 | }); 114 | 115 | test('Singleton state machine', () => { 116 | new SslServerTest(stack, 'SslServerTest1', { 117 | host: 'host1', 118 | }); 119 | 120 | new SslServerTest(stack, 'SslServerTest2', { 121 | host: 'host2', 122 | }); 123 | 124 | Template.fromStack(stack).resourceCountIs('AWS::StepFunctions::StateMachine', 1); 125 | }); 126 | -------------------------------------------------------------------------------- /test/state-machine-cr-provider/http.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock'; 2 | import * as http from '../../src/state-machine-cr-provider/runtime/http'; 3 | 4 | beforeEach(() => { 5 | nock.cleanAll(); 6 | }); 7 | 8 | test('respond', async () => { 9 | const request = nock('https://localhost') 10 | .put('/', body => 11 | body.Status === 'SUCCESS' && 12 | body.PhysicalResourceId === 'physical-resource-id' && 13 | body.Data.Key === 'Value', 14 | ) 15 | .reply(200); 16 | 17 | await http.respond('SUCCESS', { 18 | LogicalResourceId: 'logical-resource-id', 19 | RequestId: 'request-id', 20 | ResponseURL: 'https://localhost', 21 | StackId: 'stack-id', 22 | PhysicalResourceId: 'physical-resource-id', 23 | Data: { 24 | Key: 'Value', 25 | }, 26 | }); 27 | 28 | expect(request.isDone()).toBeTruthy(); 29 | }); 30 | 31 | test('respond without physical resource id', async () => { 32 | const request = nock('https://localhost') 33 | .put('/', body => body.PhysicalResourceId === http.MISSING_PHYSICAL_ID_MARKER) 34 | .reply(200); 35 | 36 | await http.respond('SUCCESS', { 37 | LogicalResourceId: 'logical-resource-id', 38 | RequestId: 'request-id', 39 | ResponseURL: 'https://localhost', 40 | StackId: 'stack-id', 41 | }); 42 | 43 | expect(request.isDone()).toBeTruthy(); 44 | }); 45 | -------------------------------------------------------------------------------- /test/state-machine-cr-provider/runtime.test.ts: -------------------------------------------------------------------------------- 1 | import 'aws-sdk-client-mock-jest'; 2 | import { SFNClient, StartExecutionCommand } from '@aws-sdk/client-sfn'; 3 | import { mockClient } from 'aws-sdk-client-mock'; 4 | import * as runtime from '../../src/state-machine-cr-provider/runtime'; 5 | import * as http from '../../src/state-machine-cr-provider/runtime/http'; 6 | 7 | jest.mock('../../src/state-machine-cr-provider/runtime/http'); 8 | 9 | const sfnClientMock = mockClient(SFNClient); 10 | 11 | beforeEach(() => { 12 | sfnClientMock.reset(); 13 | jest.clearAllMocks(); 14 | process.env.STATE_MACHINE_ARN = 'state-machine-arn'; 15 | }); 16 | 17 | const cfEvent: AWSLambda.CloudFormationCustomResourceEvent & { PhysicalResourceId?: string } = { 18 | RequestType: 'Create', 19 | ServiceToken: 'service-token', 20 | ResponseURL: 'response-url', 21 | StackId: 'stack-id', 22 | RequestId: 'request-id', 23 | LogicalResourceId: 'logical-resource-id', 24 | ResourceType: 'Custom::StateMachine', 25 | ResourceProperties: { 26 | ServiceToken: 'service-token', 27 | PropKey: 'PropValue', 28 | }, 29 | }; 30 | 31 | test('cfnResponseSuccess with CREATE', async () => { 32 | await runtime.cfnResponseSuccess({ 33 | ExecutionArn: 'execution-arn', 34 | Input: cfEvent, 35 | Name: 'execution-name', 36 | Output: { 37 | PhysicalResourceId: 'physical-resource-id', 38 | Data: { 39 | DataKey: 'DataValue', 40 | }, 41 | }, 42 | StartDate: 12345678, 43 | StateMachineArn: 'state-machine-arn', 44 | Status: 'SUCCEEDED', 45 | StopDate: 12345679, 46 | }); 47 | 48 | expect(http.respond).toHaveBeenCalledWith('SUCCESS', expect.objectContaining({ 49 | Data: { 50 | DataKey: 'DataValue', 51 | }, 52 | LogicalResourceId: 'logical-resource-id', 53 | PhysicalResourceId: 'physical-resource-id', 54 | RequestId: 'request-id', 55 | RequestType: 'Create', 56 | ResponseURL: 'response-url', 57 | })); 58 | }); 59 | 60 | test('cfnResponseSuccess with Create and no physical resource id', async () => { 61 | await runtime.cfnResponseSuccess({ 62 | ExecutionArn: 'execution-arn', 63 | Input: cfEvent, 64 | Name: 'execution-name', 65 | Output: { 66 | Data: { 67 | DataKey: 'DataValue', 68 | }, 69 | }, 70 | StartDate: 12345678, 71 | StateMachineArn: 'state-machine-arn', 72 | Status: 'SUCCEEDED', 73 | StopDate: 12345679, 74 | }); 75 | 76 | expect(http.respond).toHaveBeenCalledWith('SUCCESS', expect.objectContaining({ 77 | PhysicalResourceId: 'request-id', 78 | })); 79 | }); 80 | 81 | test('cfnResponseFailed with Create', async () => { 82 | const cause = { 83 | Input: JSON.stringify(cfEvent), 84 | }; 85 | await runtime.cfnResponseFailed({ 86 | Cause: JSON.stringify(cause), 87 | Error: 'CreateError', 88 | }); 89 | 90 | expect(http.respond).toHaveBeenCalledWith('FAILED', expect.objectContaining({ 91 | LogicalResourceId: 'logical-resource-id', 92 | PhysicalResourceId: runtime.CREATE_FAILED_PHYSICAL_ID_MARKER, 93 | RequestId: 'request-id', 94 | RequestType: 'Create', 95 | ResponseURL: 'response-url', 96 | Reason: expect.stringMatching(/^CreateError:/), 97 | })); 98 | }); 99 | 100 | test('cfnResponseFailed with Update', async () => { 101 | const cause = { 102 | Input: JSON.stringify({ 103 | ...cfEvent, 104 | RequestType: 'Update', 105 | PhysicalResourceId: 'physical-resource-id', 106 | }), 107 | }; 108 | await runtime.cfnResponseFailed({ 109 | Cause: JSON.stringify(cause), 110 | Error: 'UpdateError', 111 | }); 112 | 113 | expect(http.respond).toHaveBeenCalledWith('FAILED', expect.objectContaining({ 114 | LogicalResourceId: 'logical-resource-id', 115 | PhysicalResourceId: 'physical-resource-id', 116 | RequestId: 'request-id', 117 | RequestType: 'Update', 118 | ResponseURL: 'response-url', 119 | Reason: expect.stringMatching(/^UpdateError:/), 120 | })); 121 | }); 122 | 123 | test('startExecution with Create', async () => { 124 | await runtime.startExecution(cfEvent); 125 | 126 | expect(sfnClientMock).toHaveReceivedCommandWith(StartExecutionCommand, { 127 | stateMachineArn: 'state-machine-arn', 128 | input: JSON.stringify(cfEvent), 129 | }); 130 | }); 131 | 132 | test('startExecution with Delete after failed Create', async () => { 133 | await runtime.startExecution({ 134 | ...cfEvent, 135 | RequestType: 'Delete', 136 | PhysicalResourceId: runtime.CREATE_FAILED_PHYSICAL_ID_MARKER, 137 | }); 138 | 139 | expect(sfnClientMock).not.toHaveReceivedCommand(StartExecutionCommand); 140 | expect(http.respond).toHaveBeenCalledWith('SUCCESS', expect.anything()); 141 | }); 142 | 143 | test('startExecution with error', async () => { 144 | sfnClientMock.on(StartExecutionCommand).rejects(new Error('UnknownError')); 145 | 146 | await runtime.startExecution(cfEvent); 147 | 148 | expect(http.respond).toHaveBeenCalledWith('FAILED', expect.objectContaining({ 149 | Reason: 'UnknownError', 150 | })); 151 | }); 152 | -------------------------------------------------------------------------------- /test/state-machine-cr-provider/state-machine-cr-provider.test.ts: -------------------------------------------------------------------------------- 1 | import { CustomResource, Stack } from 'aws-cdk-lib'; 2 | import { Template } from 'aws-cdk-lib/assertions'; 3 | import { StateMachineCustomResourceProvider } from '../../src'; 4 | 5 | let stack: Stack; 6 | beforeEach(() => { 7 | stack = new Stack(); 8 | }); 9 | 10 | test('StateMachineCustomResourceProvider', () => { 11 | // WHEN 12 | const provider = new StateMachineCustomResourceProvider(stack, 'Provider', { 13 | stateMachine: { 14 | stateMachineArn: 'arn:aws:states:us-east-1:123456789012:stateMachine:my-machine', 15 | }, 16 | }); 17 | 18 | new CustomResource(stack, 'CustomResource', { 19 | serviceToken: provider.serviceToken, 20 | }); 21 | 22 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 23 | }); 24 | -------------------------------------------------------------------------------- /test/static-website/origin-request-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { handler } from '../../src/static-website/origin-request.edge-lambda'; 2 | 3 | test('without extension', async () => { 4 | const request = await handler({ 5 | Records: [ 6 | { 7 | cf: { 8 | request: { 9 | uri: '/my-uri', 10 | }, 11 | }, 12 | }, 13 | ], 14 | } as AWSLambda.CloudFrontRequestEvent); 15 | 16 | expect(request.uri).toBe('/index.html'); 17 | }); 18 | 19 | test('with extension', async () => { 20 | const request = await handler({ 21 | Records: [ 22 | { 23 | cf: { 24 | request: { 25 | uri: '/style/cool.css', 26 | }, 27 | }, 28 | }, 29 | ], 30 | } as AWSLambda.CloudFrontRequestEvent); 31 | 32 | expect(request.uri).toBe('/style/cool.css'); 33 | }); 34 | -------------------------------------------------------------------------------- /test/static-website/static-website.test.ts: -------------------------------------------------------------------------------- 1 | import { App, Stack } from 'aws-cdk-lib'; 2 | import { Template } from 'aws-cdk-lib/assertions'; 3 | import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2'; 4 | import * as integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations'; 5 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 6 | import * as route53 from 'aws-cdk-lib/aws-route53'; 7 | import { StaticWebsite } from '../../src'; 8 | 9 | let stack: Stack; 10 | let app: App; 11 | beforeEach(() => { 12 | app = new App(); 13 | stack = new Stack(app, 'Stack', { 14 | env: { region: 'eu-west-1' }, 15 | }); 16 | }); 17 | 18 | test('StaticWebsite', () => { 19 | const hostedZone = new route53.HostedZone(stack, 'HostedZone', { zoneName: 'my-site.com' }); 20 | const api = new apigatewayv2.HttpApi(stack, 'Api', { 21 | defaultIntegration: new integrations.HttpLambdaIntegration('Integration', new lambda.Function(stack, 'Fn', { 22 | code: lambda.Code.fromInline('inline'), 23 | handler: 'index.handler', 24 | runtime: new lambda.Runtime('nodejs22.x', lambda.RuntimeFamily.NODEJS, { supportsInlineCode: true }), 25 | })), 26 | }); 27 | 28 | new StaticWebsite(stack, 'StaticWebsite', { 29 | domainName: 'www.my-site.com', 30 | hostedZone, 31 | backendConfiguration: { 32 | key1: 'value1', 33 | key2: 'value2', 34 | apiUrl: api.url, 35 | }, 36 | }); 37 | 38 | // The EdgeFunction construct creates multiple stacks 39 | const assembly = app.synth(); 40 | for (const stackArtifact of assembly.stacks) { 41 | expect(stackArtifact.template).toMatchSnapshot(); 42 | } 43 | }); 44 | 45 | test('no default redirect if domainName is zoneName', () => { 46 | const hostedZone = new route53.HostedZone(stack, 'HostedZone', { zoneName: 'test.zone.com' }); 47 | 48 | new StaticWebsite(stack, 'StaticWebsite', { 49 | domainName: 'test.zone.com', 50 | hostedZone, 51 | }); 52 | 53 | Template.fromStack(stack).resourceCountIs('AWS::CloudFront::Distribution', 1); 54 | }); 55 | -------------------------------------------------------------------------------- /test/toolkit-cleaner/clean-images.test.ts: -------------------------------------------------------------------------------- 1 | import 'aws-sdk-client-mock-jest'; 2 | import { BatchDeleteImageCommand, DescribeImagesCommand, ECRClient } from '@aws-sdk/client-ecr'; 3 | import { mockClient } from 'aws-sdk-client-mock'; 4 | import { handler } from '../../src/toolkit-cleaner/clean-images.lambda'; 5 | 6 | const ecrClientMock = mockClient(ECRClient); 7 | 8 | beforeEach(() => { 9 | ecrClientMock.reset(); 10 | ecrClientMock.on(DescribeImagesCommand) 11 | .resolvesOnce({ 12 | imageDetails: [ 13 | { 14 | imageTags: ['hash1'], 15 | imagePushedAt: daysAgo(5), 16 | imageSizeInBytes: 12, 17 | }, 18 | { 19 | imageTags: ['hash2'], 20 | imagePushedAt: new Date(), 21 | imageSizeInBytes: 15, 22 | }, 23 | ], 24 | nextToken: 'token', 25 | }) 26 | .resolvesOnce({ 27 | imageDetails: [ 28 | { 29 | imageTags: ['hash3'], 30 | imagePushedAt: daysAgo(30), 31 | imageSizeInBytes: 9, 32 | }, 33 | { 34 | imageTags: ['hash4'], 35 | imagePushedAt: new Date(), 36 | imageSizeInBytes: 11, 37 | }, 38 | ], 39 | }); 40 | 41 | process.env.REPOSITORY_NAME = 'repository'; 42 | process.env.RUN = 'true'; 43 | }); 44 | 45 | test('cleans unused images', async () => { 46 | const response = await handler(['hash2', 'hash4']); 47 | 48 | expect(ecrClientMock).toHaveReceivedCommandWith(DescribeImagesCommand, { 49 | repositoryName: 'repository', 50 | }); 51 | 52 | expect(ecrClientMock).toHaveReceivedCommandTimes(BatchDeleteImageCommand, 2); 53 | expect(ecrClientMock).toHaveReceivedCommandWith(BatchDeleteImageCommand, { 54 | repositoryName: 'repository', 55 | imageIds: [{ imageTag: 'hash1' }], 56 | }); 57 | expect(ecrClientMock).toHaveReceivedCommandWith(BatchDeleteImageCommand, { 58 | repositoryName: 'repository', 59 | imageIds: [{ imageTag: 'hash3' }], 60 | }); 61 | 62 | expect(response).toEqual({ 63 | Deleted: 2, 64 | Reclaimed: 21, 65 | }); 66 | }); 67 | 68 | test('without RUN', async () => { 69 | delete process.env.RUN; 70 | 71 | const response = await handler(['hash2', 'hash4']); 72 | 73 | expect(ecrClientMock).not.toHaveReceivedCommand(BatchDeleteImageCommand); 74 | 75 | expect(response).toEqual({ 76 | Deleted: 2, 77 | Reclaimed: 21, 78 | }); 79 | }); 80 | 81 | test('with RETAIN_MILLISECONDS', async () => { 82 | // 10 days 83 | process.env.RETAIN_MILLISECONDS = (10 * 24 * 3600 * 1000).toString(); 84 | 85 | const response = await handler(['hash2', 'hash4']); 86 | 87 | expect(ecrClientMock).toHaveReceivedCommandTimes(BatchDeleteImageCommand, 1); 88 | expect(ecrClientMock).toHaveReceivedCommandWith(BatchDeleteImageCommand, { 89 | repositoryName: 'repository', 90 | imageIds: [{ imageTag: 'hash3' }], 91 | }); 92 | 93 | expect(response).toEqual({ 94 | Deleted: 1, 95 | Reclaimed: 9, 96 | }); 97 | 98 | delete process.env.RETAIN_MILLISECONDS; 99 | }); 100 | 101 | function daysAgo(days: number): Date { 102 | const ret = new Date(); 103 | ret.setDate(ret.getDate() - days); 104 | return ret; 105 | } 106 | -------------------------------------------------------------------------------- /test/toolkit-cleaner/clean-objects.test.ts: -------------------------------------------------------------------------------- 1 | import 'aws-sdk-client-mock-jest'; 2 | import { DeleteObjectsCommand, ListObjectVersionsCommand, S3Client } from '@aws-sdk/client-s3'; 3 | import { mockClient } from 'aws-sdk-client-mock'; 4 | import { handler } from '../../src/toolkit-cleaner/clean-objects.lambda'; 5 | 6 | const s3ClientMock = mockClient(S3Client); 7 | 8 | beforeEach(() => { 9 | s3ClientMock.reset(); 10 | s3ClientMock.on(ListObjectVersionsCommand) 11 | .resolvesOnce({ 12 | Versions: [ 13 | { 14 | Key: 'hash1.json', 15 | LastModified: daysAgo(5), 16 | Size: 12, 17 | VersionId: 'hash1-version-id', 18 | }, 19 | { 20 | Key: 'hash2.zip', 21 | LastModified: new Date(), 22 | Size: 15, 23 | VersionId: 'hash2-version-id', 24 | }, 25 | ], 26 | NextKeyMarker: 'marker', 27 | }) 28 | .resolvesOnce({ 29 | Versions: [ 30 | { 31 | Key: 'hash3.zip', 32 | LastModified: daysAgo(30), 33 | Size: 9, 34 | VersionId: 'hash3-version-id', 35 | }, 36 | { 37 | Key: 'hash4.zip', 38 | LastModified: new Date(), 39 | Size: 11, 40 | VersionId: 'hash4-version-id', 41 | }, 42 | ], 43 | }); 44 | 45 | process.env.BUCKET_NAME = 'bucket'; 46 | process.env.RUN = 'true'; 47 | }); 48 | 49 | test('cleans unused objects', async () => { 50 | const response = await handler(['hash2', 'hash4']); 51 | 52 | expect(s3ClientMock).toHaveReceivedCommandWith(ListObjectVersionsCommand, { 53 | Bucket: 'bucket', 54 | }); 55 | 56 | expect(s3ClientMock).toHaveReceivedCommandTimes(DeleteObjectsCommand, 2); 57 | expect(s3ClientMock).toHaveReceivedCommandWith(DeleteObjectsCommand, { 58 | Bucket: 'bucket', 59 | Delete: { 60 | Objects: [{ Key: 'hash1.json', VersionId: 'hash1-version-id' }], 61 | }, 62 | }); 63 | expect(s3ClientMock).toHaveReceivedCommandWith(DeleteObjectsCommand, { 64 | Bucket: 'bucket', 65 | Delete: { 66 | Objects: [{ Key: 'hash3.zip', VersionId: 'hash3-version-id' }], 67 | }, 68 | }); 69 | 70 | expect(response).toEqual({ 71 | Deleted: 2, 72 | Reclaimed: 21, 73 | }); 74 | }); 75 | 76 | test('without RUN', async () => { 77 | delete process.env.RUN; 78 | 79 | const response = await handler(['hash2', 'hash4']); 80 | 81 | expect(s3ClientMock).not.toHaveReceivedCommand(DeleteObjectsCommand); 82 | 83 | expect(response).toEqual({ 84 | Deleted: 2, 85 | Reclaimed: 21, 86 | }); 87 | }); 88 | 89 | test('with RETAIN_MILLISECONDS', async () => { 90 | // 10 days 91 | process.env.RETAIN_MILLISECONDS = (10 * 24 * 3600 * 1000).toString(); 92 | 93 | const response = await handler(['hash2', 'hash4']); 94 | 95 | expect(s3ClientMock).toHaveReceivedCommandTimes(DeleteObjectsCommand, 1); 96 | expect(s3ClientMock).toHaveReceivedCommandWith(DeleteObjectsCommand, { 97 | Bucket: 'bucket', 98 | Delete: { 99 | Objects: [{ Key: 'hash3.zip', VersionId: 'hash3-version-id' }], 100 | }, 101 | }); 102 | 103 | expect(response).toEqual({ 104 | Deleted: 1, 105 | Reclaimed: 9, 106 | }); 107 | 108 | delete process.env.RETAIN_MILLISECONDS; 109 | }); 110 | 111 | function daysAgo(days: number): Date { 112 | const ret = new Date(); 113 | ret.setDate(ret.getDate() - days); 114 | return ret; 115 | } 116 | -------------------------------------------------------------------------------- /test/toolkit-cleaner/extract-template-hashes.test.ts: -------------------------------------------------------------------------------- 1 | import 'aws-sdk-client-mock-jest'; 2 | import { CloudFormationClient, GetTemplateCommand } from '@aws-sdk/client-cloudformation'; 3 | import { mockClient } from 'aws-sdk-client-mock'; 4 | import { handler } from '../../src/toolkit-cleaner/extract-template-hashes.lambda'; 5 | 6 | const cloudFormationClientMock = mockClient(CloudFormationClient); 7 | 8 | test('extracts hashes', async () => { 9 | process.env.DOCKER_IMAGE_ASSET_HASH = '5a7abf30ce10141adcb73c9b836ec68479c65fb4d1693df160563e36ece0d55e'; 10 | 11 | cloudFormationClientMock.on(GetTemplateCommand).resolves({ 12 | TemplateBody: 'hello 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 world 486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7', 13 | }); 14 | 15 | const response = await handler('stack1'); 16 | 17 | expect(cloudFormationClientMock).toHaveReceivedCommandWith(GetTemplateCommand, { StackName: 'stack1' }); 18 | 19 | expect(response).toEqual([ 20 | '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', 21 | '486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7', 22 | ]); 23 | }); 24 | 25 | test('extracts hashes with docker tag prefix', async () => { 26 | process.env.DOCKER_IMAGE_ASSET_HASH = 'prefix5a7abf30ce10141adcb73c9b836ec68479c65fb4d1693df160563e36ece0d55e'; 27 | 28 | cloudFormationClientMock.on(GetTemplateCommand).resolves({ 29 | TemplateBody: 'hello prefix2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 world 486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7', 30 | }); 31 | 32 | const response = await handler('stack1'); 33 | 34 | expect(cloudFormationClientMock).toHaveReceivedCommandWith(GetTemplateCommand, { StackName: 'stack1' }); 35 | 36 | expect(response).toEqual([ 37 | 'prefix2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', 38 | '486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7', 39 | ]); 40 | }); 41 | -------------------------------------------------------------------------------- /test/toolkit-cleaner/get-stack-names.test.ts: -------------------------------------------------------------------------------- 1 | import 'aws-sdk-client-mock-jest'; 2 | import { CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; 3 | import { mockClient } from 'aws-sdk-client-mock'; 4 | import { handler } from '../../src/toolkit-cleaner/get-stack-names.lambda'; 5 | 6 | const cloudFormationClientMock = mockClient(CloudFormationClient); 7 | cloudFormationClientMock.on(DescribeStacksCommand) 8 | .resolvesOnce({ 9 | Stacks: [ 10 | { StackName: 'stack1', CreationTime: new Date(), StackStatus: 'CREATE_COMPLETE' }, 11 | { StackName: 'stack2', CreationTime: new Date(), StackStatus: 'CREATE_COMPLETE' }, 12 | ], 13 | NextToken: 'token', 14 | }) 15 | .resolvesOnce({ 16 | Stacks: [{ StackName: 'stack3', CreationTime: new Date(), StackStatus: 'CREATE_COMPLETE' }], 17 | }); 18 | 19 | test('returns a list of stack names', async () => { 20 | 21 | const response = await handler(); 22 | 23 | expect(response).toEqual(['stack1', 'stack2', 'stack3']); 24 | }); 25 | -------------------------------------------------------------------------------- /test/toolkit-cleaner/toolkit-cleaner.integ.snapshot/toolkit-cleaner-integ.assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "36.0.0", 3 | "files": { 4 | "95c924c84f5d023be4edee540cb2cb401a49f115d01ed403b288f6cb412771df": { 5 | "source": { 6 | "path": "asset.95c924c84f5d023be4edee540cb2cb401a49f115d01ed403b288f6cb412771df.txt", 7 | "packaging": "file" 8 | }, 9 | "destinations": { 10 | "current_account-current_region": { 11 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", 12 | "objectKey": "95c924c84f5d023be4edee540cb2cb401a49f115d01ed403b288f6cb412771df.txt", 13 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" 14 | } 15 | } 16 | }, 17 | "07a8a9f64e4f11caaceb5d9301e9c63b6e963707210dae147c66aeb54980a096": { 18 | "source": { 19 | "path": "asset.07a8a9f64e4f11caaceb5d9301e9c63b6e963707210dae147c66aeb54980a096.lambda", 20 | "packaging": "zip" 21 | }, 22 | "destinations": { 23 | "current_account-current_region": { 24 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", 25 | "objectKey": "07a8a9f64e4f11caaceb5d9301e9c63b6e963707210dae147c66aeb54980a096.zip", 26 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" 27 | } 28 | } 29 | }, 30 | "839231c3a66de2124ed53cba376ac7d09e962b10a04d77f96cd2f74e930f75c9": { 31 | "source": { 32 | "path": "asset.839231c3a66de2124ed53cba376ac7d09e962b10a04d77f96cd2f74e930f75c9.lambda", 33 | "packaging": "zip" 34 | }, 35 | "destinations": { 36 | "current_account-current_region": { 37 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", 38 | "objectKey": "839231c3a66de2124ed53cba376ac7d09e962b10a04d77f96cd2f74e930f75c9.zip", 39 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" 40 | } 41 | } 42 | }, 43 | "3645f27f99a6fa2216b10fb815a7b033cc62a41574dc385c5d4db00d2b2fe32a": { 44 | "source": { 45 | "path": "asset.3645f27f99a6fa2216b10fb815a7b033cc62a41574dc385c5d4db00d2b2fe32a", 46 | "packaging": "zip" 47 | }, 48 | "destinations": { 49 | "current_account-current_region": { 50 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", 51 | "objectKey": "3645f27f99a6fa2216b10fb815a7b033cc62a41574dc385c5d4db00d2b2fe32a.zip", 52 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" 53 | } 54 | } 55 | }, 56 | "4ea9f49483cffdae1260d2683df7bdb4ec1cc6afc1cfe1b8e6772adc24f43ced": { 57 | "source": { 58 | "path": "asset.4ea9f49483cffdae1260d2683df7bdb4ec1cc6afc1cfe1b8e6772adc24f43ced.lambda", 59 | "packaging": "zip" 60 | }, 61 | "destinations": { 62 | "current_account-current_region": { 63 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", 64 | "objectKey": "4ea9f49483cffdae1260d2683df7bdb4ec1cc6afc1cfe1b8e6772adc24f43ced.zip", 65 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" 66 | } 67 | } 68 | }, 69 | "c35f283ae2dd66b8baf5296da1c747728ac2ee89c9bcd86d33de4beae5f7569d": { 70 | "source": { 71 | "path": "asset.c35f283ae2dd66b8baf5296da1c747728ac2ee89c9bcd86d33de4beae5f7569d.lambda", 72 | "packaging": "zip" 73 | }, 74 | "destinations": { 75 | "current_account-current_region": { 76 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", 77 | "objectKey": "c35f283ae2dd66b8baf5296da1c747728ac2ee89c9bcd86d33de4beae5f7569d.zip", 78 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" 79 | } 80 | } 81 | }, 82 | "9e17e5915f90568390b92060bbee6b3bf05b43753b52abd756432f244161e595": { 83 | "source": { 84 | "path": "toolkit-cleaner-integ.template.json", 85 | "packaging": "file" 86 | }, 87 | "destinations": { 88 | "current_account-current_region": { 89 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", 90 | "objectKey": "9e17e5915f90568390b92060bbee6b3bf05b43753b52abd756432f244161e595.json", 91 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" 92 | } 93 | } 94 | } 95 | }, 96 | "dockerImages": { 97 | "59bc252bbfc4819edcc48f546a0ea71b7b108d3899f8f503cd6f5bcc1f375126": { 98 | "source": { 99 | "directory": "asset.59bc252bbfc4819edcc48f546a0ea71b7b108d3899f8f503cd6f5bcc1f375126" 100 | }, 101 | "destinations": { 102 | "current_account-current_region": { 103 | "repositoryName": "cdk-hnb659fds-container-assets-${AWS::AccountId}-${AWS::Region}", 104 | "imageTag": "59bc252bbfc4819edcc48f546a0ea71b7b108d3899f8f503cd6f5bcc1f375126", 105 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-image-publishing-role-${AWS::AccountId}-${AWS::Region}" 106 | } 107 | } 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /test/toolkit-cleaner/toolkit-cleaner.integ.ts: -------------------------------------------------------------------------------- 1 | import { App, Duration, Stack, StackProps } from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import { ToolkitCleaner } from '../../src'; 4 | 5 | class TestStack extends Stack { 6 | constructor(scope: Construct, id: string, props?: StackProps) { 7 | super(scope, id, props); 8 | 9 | new ToolkitCleaner(this, 'ToolkitCleaner', { 10 | dryRun: true, 11 | retainAssetsNewerThan: Duration.days(90), 12 | }); 13 | } 14 | } 15 | 16 | const app = new App(); 17 | new TestStack(app, 'toolkit-cleaner-integ'); 18 | app.synth(); 19 | -------------------------------------------------------------------------------- /test/toolkit-cleaner/toolkit-cleaner.test.ts: -------------------------------------------------------------------------------- 1 | import { Duration, Stack } from 'aws-cdk-lib'; 2 | import { Match, Template } from 'aws-cdk-lib/assertions'; 3 | import { ToolkitCleaner } from '../../src'; 4 | 5 | let stack: Stack; 6 | beforeEach(() => { 7 | stack = new Stack(); 8 | }); 9 | 10 | test('ToolkitCleaner', () => { 11 | new ToolkitCleaner(stack, 'ToolkitCleaner'); 12 | 13 | Template.fromStack(stack).hasResourceProperties('AWS::StepFunctions::StateMachine', { 14 | RoleArn: { 15 | 'Fn::GetAtt': [ 16 | 'ToolkitCleanerRole794E8158', 17 | 'Arn', 18 | ], 19 | }, 20 | DefinitionString: { 21 | 'Fn::Join': [ 22 | '', 23 | [ 24 | '{"StartAt":"GetStackNames","States":{"GetStackNames":{"Next":"StacksMap","Retry":[{"ErrorEquals":["Lambda.ClientExecutionTimeoutException","Lambda.ServiceException","Lambda.AWSLambdaException","Lambda.SdkClientException"],"IntervalSeconds":2,"MaxAttempts":6,"BackoffRate":2}],"Type":"Task","Resource":"', 25 | { 26 | 'Fn::GetAtt': [ 27 | 'ToolkitCleanerGetStackNamesFunction362F31B8', 28 | 'Arn', 29 | ], 30 | }, 31 | '"},"StacksMap":{"Type":"Map","Next":"FlattenHashes","ResultSelector":{"AssetHashes.$":"$"},"ItemProcessor":{"ProcessorConfig":{"Mode":"INLINE"},"StartAt":"ExtractTemplateHashes","States":{"ExtractTemplateHashes":{"End":true,"Retry":[{"ErrorEquals":["Lambda.ClientExecutionTimeoutException","Lambda.ServiceException","Lambda.AWSLambdaException","Lambda.SdkClientException"],"IntervalSeconds":2,"MaxAttempts":6,"BackoffRate":2},{"ErrorEquals":["Throttling"]}],"Type":"Task","Resource":"', 32 | { 33 | 'Fn::GetAtt': [ 34 | 'ToolkitCleanerExtractTemplateHashesFunctionFFDFB6D1', 35 | 'Arn', 36 | ], 37 | }, 38 | '"}}},"MaxConcurrency":1},"FlattenHashes":{"Next":"Clean","Type":"Task","Resource":"', 39 | { 40 | 'Fn::GetAtt': [ 41 | 'Eval41256dc5445742738ed917bc818694e54EB1134F', 42 | 'Arn', 43 | ], 44 | }, 45 | '","Parameters":{"expression":"[...new Set(($.AssetHashes).flat())]","expressionAttributeValues":{"$.AssetHashes.$":"$.AssetHashes"}}},"Clean":{"Type":"Parallel","Next":"SumReclaimed","Branches":[{"StartAt":"CleanObjects","States":{"CleanObjects":{"End":true,"Retry":[{"ErrorEquals":["Lambda.ClientExecutionTimeoutException","Lambda.ServiceException","Lambda.AWSLambdaException","Lambda.SdkClientException"],"IntervalSeconds":2,"MaxAttempts":6,"BackoffRate":2}],"Type":"Task","Resource":"', 46 | { 47 | 'Fn::GetAtt': [ 48 | 'ToolkitCleanerCleanObjectsFunction23A18EAE', 49 | 'Arn', 50 | ], 51 | }, 52 | '"}}},{"StartAt":"CleanImages","States":{"CleanImages":{"End":true,"Retry":[{"ErrorEquals":["Lambda.ClientExecutionTimeoutException","Lambda.ServiceException","Lambda.AWSLambdaException","Lambda.SdkClientException"],"IntervalSeconds":2,"MaxAttempts":6,"BackoffRate":2}],"Type":"Task","Resource":"', 53 | { 54 | 'Fn::GetAtt': [ 55 | 'ToolkitCleanerCleanImagesFunction96CABD19', 56 | 'Arn', 57 | ], 58 | }, 59 | '"}}}]},"SumReclaimed":{"End":true,"Type":"Task","Resource":"', 60 | { 61 | 'Fn::GetAtt': [ 62 | 'Eval41256dc5445742738ed917bc818694e54EB1134F', 63 | 'Arn', 64 | ], 65 | }, 66 | '","Parameters":{"expression":"({ Deleted: $[0].Deleted + $[1].Deleted, Reclaimed: $[0].Reclaimed + $[1].Reclaimed })","expressionAttributeValues":{"$[0].Deleted.$":"$[0].Deleted","$[1].Deleted.$":"$[1].Deleted","$[0].Reclaimed.$":"$[0].Reclaimed","$[1].Reclaimed.$":"$[1].Reclaimed"}}}}}', 67 | ], 68 | ], 69 | }, 70 | }); 71 | 72 | Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Function', { 73 | Environment: { 74 | Variables: Match.objectLike({ 75 | RUN: 'true', 76 | }), 77 | }, 78 | Timeout: 300, 79 | }); 80 | 81 | Template.fromStack(stack).hasResourceProperties('AWS::Events::Rule', { 82 | ScheduleExpression: 'rate(1 day)', 83 | State: 'ENABLED', 84 | Targets: [ 85 | { 86 | Arn: { 87 | Ref: 'ToolkitCleanerC02E18EA', 88 | }, 89 | Id: 'Target0', 90 | RoleArn: { 91 | 'Fn::GetAtt': [ 92 | 'ToolkitCleanerEventsRole16CFA1D4', 93 | 'Arn', 94 | ], 95 | }, 96 | }, 97 | ], 98 | }); 99 | }); 100 | 101 | test('with dry run', () => { 102 | new ToolkitCleaner(stack, 'ToolkitCleaner', { 103 | dryRun: true, 104 | }); 105 | 106 | Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Function', { 107 | Environment: { 108 | Variables: Match.objectLike({ 109 | RUN: Match.absent(), 110 | }), 111 | }, 112 | }); 113 | }); 114 | 115 | test('with retainAssetsNewerThan', () => { 116 | new ToolkitCleaner(stack, 'ToolkitCleaner', { 117 | retainAssetsNewerThan: Duration.days(90), 118 | }); 119 | 120 | Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Function', { 121 | Environment: { 122 | Variables: Match.objectLike({ 123 | RETAIN_MILLISECONDS: '7776000000', 124 | }), 125 | }, 126 | }); 127 | }); 128 | 129 | test('with scheduleEnabled set to false', () => { 130 | new ToolkitCleaner(stack, 'ToolkitCleaner', { 131 | scheduleEnabled: false, 132 | }); 133 | 134 | Template.fromStack(stack).hasResourceProperties('AWS::Events::Rule', { 135 | State: 'DISABLED', 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /test/url-shortener/shortener.test.ts: -------------------------------------------------------------------------------- 1 | import 'aws-sdk-client-mock-jest'; 2 | import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; 3 | import { DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb'; 4 | import { mockClient } from 'aws-sdk-client-mock'; 5 | import { handler } from '../../src/url-shortener/shortener.lambda'; 6 | 7 | const s3ClientMock = mockClient(S3Client); 8 | const documentClientMock = mockClient(DynamoDBDocumentClient); 9 | 10 | beforeEach(() => { 11 | s3ClientMock.reset(); 12 | documentClientMock.reset(); 13 | documentClientMock.on(UpdateCommand).resolves({ 14 | Attributes: { value: 1000 }, 15 | }); 16 | }); 17 | 18 | process.env.TABLE_NAME = 'my-table'; 19 | process.env.DOMAIN_NAME = 'short.com'; 20 | process.env.BUCKET_NAME = 'bucket'; 21 | 22 | test('returns 201 with short url', async () => { 23 | const response = await handler({ 24 | body: JSON.stringify({ 25 | url: 'https://www.url.com/very/long', 26 | }), 27 | } as AWSLambda.APIGatewayProxyEvent); 28 | 29 | expect(response).toEqual({ 30 | statusCode: 201, 31 | body: JSON.stringify({ 32 | url: 'https://www.url.com/very/long', 33 | shortUrl: 'https://short.com/QI', 34 | }), 35 | }); 36 | 37 | expect(documentClientMock).toHaveReceivedCommandWith(UpdateCommand, { 38 | TableName: 'my-table', 39 | Key: { key: 'counter' }, 40 | UpdateExpression: 'ADD #value :incr', 41 | ExpressionAttributeNames: { '#value': 'value' }, 42 | ExpressionAttributeValues: { ':incr': 1 }, 43 | ReturnValues: 'UPDATED_NEW', 44 | }); 45 | 46 | expect(s3ClientMock).toHaveReceivedCommandWith(PutObjectCommand, { 47 | Bucket: 'bucket', 48 | Key: 'QI', 49 | ContentType: 'application/json', 50 | Body: JSON.stringify({ url: 'https://www.url.com/very/long' }), 51 | }); 52 | }); 53 | 54 | test('returns 400 with invalid url', async () => { 55 | const response = await handler({ 56 | body: JSON.stringify({ 57 | url: 'hello', 58 | }), 59 | } as AWSLambda.APIGatewayProxyEvent); 60 | 61 | expect(response).toEqual({ 62 | statusCode: 400, 63 | body: '', 64 | }); 65 | 66 | expect(documentClientMock).not.toHaveReceivedCommand(UpdateCommand); 67 | 68 | expect(s3ClientMock).not.toHaveReceivedCommand(PutObjectCommand); 69 | }); 70 | -------------------------------------------------------------------------------- /test/url-shortener/url-shortener.integ.snapshot/edge-lambda-stack-c8e731c8ad0787291628b399d525c90dc78319b7d7.assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "36.0.0", 3 | "files": { 4 | "ef50eada816754194800ebd82b6d5459ee1af84a5fbdfcb142b289e893b6fa07": { 5 | "source": { 6 | "path": "asset.ef50eada816754194800ebd82b6d5459ee1af84a5fbdfcb142b289e893b6fa07.edge-lambda", 7 | "packaging": "zip" 8 | }, 9 | "destinations": { 10 | "current_account-us-east-1": { 11 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-us-east-1", 12 | "objectKey": "ef50eada816754194800ebd82b6d5459ee1af84a5fbdfcb142b289e893b6fa07.zip", 13 | "region": "us-east-1", 14 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-us-east-1" 15 | } 16 | } 17 | }, 18 | "5e1aa3079e0e464f5a0de2a8655eb7c456069b38ab6ceafd727ee07f292e009b": { 19 | "source": { 20 | "path": "edge-lambda-stack-c8e731c8ad0787291628b399d525c90dc78319b7d7.template.json", 21 | "packaging": "file" 22 | }, 23 | "destinations": { 24 | "current_account-us-east-1": { 25 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-us-east-1", 26 | "objectKey": "5e1aa3079e0e464f5a0de2a8655eb7c456069b38ab6ceafd727ee07f292e009b.json", 27 | "region": "us-east-1", 28 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-us-east-1" 29 | } 30 | } 31 | } 32 | }, 33 | "dockerImages": {} 34 | } -------------------------------------------------------------------------------- /test/url-shortener/url-shortener.integ.snapshot/edge-lambda-stack-c8e731c8ad0787291628b399d525c90dc78319b7d7.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "Resources": { 3 | "RedirectServiceRole39B6C513": { 4 | "Type": "AWS::IAM::Role", 5 | "Properties": { 6 | "AssumeRolePolicyDocument": { 7 | "Statement": [ 8 | { 9 | "Action": "sts:AssumeRole", 10 | "Effect": "Allow", 11 | "Principal": { 12 | "Service": "lambda.amazonaws.com" 13 | } 14 | }, 15 | { 16 | "Action": "sts:AssumeRole", 17 | "Effect": "Allow", 18 | "Principal": { 19 | "Service": "edgelambda.amazonaws.com" 20 | } 21 | } 22 | ], 23 | "Version": "2012-10-17" 24 | }, 25 | "ManagedPolicyArns": [ 26 | { 27 | "Fn::Join": [ 28 | "", 29 | [ 30 | "arn:", 31 | { 32 | "Ref": "AWS::Partition" 33 | }, 34 | ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" 35 | ] 36 | ] 37 | } 38 | ] 39 | } 40 | }, 41 | "RedirectServiceRoleDefaultPolicy53F16DEF": { 42 | "Type": "AWS::IAM::Policy", 43 | "Properties": { 44 | "PolicyDocument": { 45 | "Statement": [ 46 | { 47 | "Action": [ 48 | "s3:GetObject*", 49 | "s3:GetBucket*", 50 | "s3:List*" 51 | ], 52 | "Effect": "Allow", 53 | "Resource": [ 54 | { 55 | "Fn::Join": [ 56 | "", 57 | [ 58 | "arn:", 59 | { 60 | "Ref": "AWS::Partition" 61 | }, 62 | ":s3:::cloudstructs-url-shortener-short.goldex.be" 63 | ] 64 | ] 65 | }, 66 | { 67 | "Fn::Join": [ 68 | "", 69 | [ 70 | "arn:", 71 | { 72 | "Ref": "AWS::Partition" 73 | }, 74 | ":s3:::cloudstructs-url-shortener-short.goldex.be/*" 75 | ] 76 | ] 77 | } 78 | ] 79 | } 80 | ], 81 | "Version": "2012-10-17" 82 | }, 83 | "PolicyName": "RedirectServiceRoleDefaultPolicy53F16DEF", 84 | "Roles": [ 85 | { 86 | "Ref": "RedirectServiceRole39B6C513" 87 | } 88 | ] 89 | } 90 | }, 91 | "Redirect7D9319B2": { 92 | "Type": "AWS::Lambda::Function", 93 | "Properties": { 94 | "Code": { 95 | "S3Bucket": { 96 | "Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-us-east-1" 97 | }, 98 | "S3Key": "ef50eada816754194800ebd82b6d5459ee1af84a5fbdfcb142b289e893b6fa07.zip" 99 | }, 100 | "Description": "src/url-shortener/redirect.edge-lambda.ts", 101 | "Handler": "index.handler", 102 | "Role": { 103 | "Fn::GetAtt": [ 104 | "RedirectServiceRole39B6C513", 105 | "Arn" 106 | ] 107 | }, 108 | "Runtime": "nodejs22.x" 109 | }, 110 | "DependsOn": [ 111 | "RedirectServiceRoleDefaultPolicy53F16DEF", 112 | "RedirectServiceRole39B6C513" 113 | ] 114 | }, 115 | "RedirectCurrentVersion479E25EC0f3d7e82490b3ab600fd9d2aee4f2581": { 116 | "Type": "AWS::Lambda::Version", 117 | "Properties": { 118 | "FunctionName": { 119 | "Ref": "Redirect7D9319B2" 120 | } 121 | } 122 | }, 123 | "RedirectParameter0BB48857": { 124 | "Type": "AWS::SSM::Parameter", 125 | "Properties": { 126 | "Name": "/cdk/EdgeFunctionArn/eu-west-1/url-shortener-integ/UrlShortener/Redirect", 127 | "Type": "String", 128 | "Value": { 129 | "Ref": "RedirectCurrentVersion479E25EC0f3d7e82490b3ab600fd9d2aee4f2581" 130 | } 131 | } 132 | } 133 | }, 134 | "Parameters": { 135 | "BootstrapVersion": { 136 | "Type": "AWS::SSM::Parameter::Value", 137 | "Default": "/cdk-bootstrap/hnb659fds/version", 138 | "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" 139 | } 140 | }, 141 | "Rules": { 142 | "CheckBootstrapVersion": { 143 | "Assertions": [ 144 | { 145 | "Assert": { 146 | "Fn::Not": [ 147 | { 148 | "Fn::Contains": [ 149 | [ 150 | "1", 151 | "2", 152 | "3", 153 | "4", 154 | "5" 155 | ], 156 | { 157 | "Ref": "BootstrapVersion" 158 | } 159 | ] 160 | } 161 | ] 162 | }, 163 | "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." 164 | } 165 | ] 166 | } 167 | } 168 | } -------------------------------------------------------------------------------- /test/url-shortener/url-shortener.integ.snapshot/url-shortener-integ.assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "36.0.0", 3 | "files": { 4 | "820cf5767b52fe3ade2023551f65be59f6a5a1d6ffbb11bc6be66146f3c37d3c": { 5 | "source": { 6 | "path": "asset.820cf5767b52fe3ade2023551f65be59f6a5a1d6ffbb11bc6be66146f3c37d3c", 7 | "packaging": "zip" 8 | }, 9 | "destinations": { 10 | "current_account-eu-west-1": { 11 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-eu-west-1", 12 | "objectKey": "820cf5767b52fe3ade2023551f65be59f6a5a1d6ffbb11bc6be66146f3c37d3c.zip", 13 | "region": "eu-west-1", 14 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-eu-west-1" 15 | } 16 | } 17 | }, 18 | "b073cebcf4d61fb152a30f5a5e57a94df7f980a549fdf1a79a0b18c5750522d8": { 19 | "source": { 20 | "path": "asset.b073cebcf4d61fb152a30f5a5e57a94df7f980a549fdf1a79a0b18c5750522d8", 21 | "packaging": "zip" 22 | }, 23 | "destinations": { 24 | "current_account-eu-west-1": { 25 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-eu-west-1", 26 | "objectKey": "b073cebcf4d61fb152a30f5a5e57a94df7f980a549fdf1a79a0b18c5750522d8.zip", 27 | "region": "eu-west-1", 28 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-eu-west-1" 29 | } 30 | } 31 | }, 32 | "b9d6c62120ffb22a5a44557d48d6261ae846d8bd49a68c3e3592af8248ba97f6": { 33 | "source": { 34 | "path": "asset.b9d6c62120ffb22a5a44557d48d6261ae846d8bd49a68c3e3592af8248ba97f6.lambda", 35 | "packaging": "zip" 36 | }, 37 | "destinations": { 38 | "current_account-eu-west-1": { 39 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-eu-west-1", 40 | "objectKey": "b9d6c62120ffb22a5a44557d48d6261ae846d8bd49a68c3e3592af8248ba97f6.zip", 41 | "region": "eu-west-1", 42 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-eu-west-1" 43 | } 44 | } 45 | }, 46 | "4e26bf2d0a26f2097fb2b261f22bb51e3f6b4b52635777b1e54edbd8e2d58c35": { 47 | "source": { 48 | "path": "asset.4e26bf2d0a26f2097fb2b261f22bb51e3f6b4b52635777b1e54edbd8e2d58c35", 49 | "packaging": "zip" 50 | }, 51 | "destinations": { 52 | "current_account-eu-west-1": { 53 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-eu-west-1", 54 | "objectKey": "4e26bf2d0a26f2097fb2b261f22bb51e3f6b4b52635777b1e54edbd8e2d58c35.zip", 55 | "region": "eu-west-1", 56 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-eu-west-1" 57 | } 58 | } 59 | }, 60 | "137f1ec8ab039e8a9062d4499a5f89de7e4adddf0a08d8f0fb2be3475b1c39ff": { 61 | "source": { 62 | "path": "url-shortener-integ.template.json", 63 | "packaging": "file" 64 | }, 65 | "destinations": { 66 | "current_account-eu-west-1": { 67 | "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-eu-west-1", 68 | "objectKey": "137f1ec8ab039e8a9062d4499a5f89de7e4adddf0a08d8f0fb2be3475b1c39ff.json", 69 | "region": "eu-west-1", 70 | "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-eu-west-1" 71 | } 72 | } 73 | } 74 | }, 75 | "dockerImages": {} 76 | } -------------------------------------------------------------------------------- /test/url-shortener/url-shortener.integ.ts: -------------------------------------------------------------------------------- 1 | import { App, CfnOutput, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib'; 2 | import * as apigateway from 'aws-cdk-lib/aws-apigateway'; 3 | import * as cognito from 'aws-cdk-lib/aws-cognito'; 4 | import * as route53 from 'aws-cdk-lib/aws-route53'; 5 | import * as s3 from 'aws-cdk-lib/aws-s3'; 6 | import { Construct } from 'constructs'; 7 | import { UrlShortener } from '../../src'; 8 | 9 | class TestStack extends Stack { 10 | constructor(scope: Construct, id: string, props?: StackProps) { 11 | super(scope, id, props); 12 | 13 | const userPool = new cognito.UserPool(this, 'UserPool', { 14 | removalPolicy: RemovalPolicy.DESTROY, 15 | }); 16 | 17 | const urlShortener = new UrlShortener(this, 'UrlShortener', { 18 | hostedZone: route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', { 19 | hostedZoneId: 'ZKEU89CLZS8GH', 20 | zoneName: 'goldex.be', 21 | }), 22 | recordName: 'short', 23 | apiGatewayAuthorizer: new apigateway.CognitoUserPoolsAuthorizer(this, 'Authorizer', { 24 | cognitoUserPools: [userPool], 25 | }), 26 | corsAllowOrigins: ['*'], 27 | }); 28 | 29 | const bucket = urlShortener.node.tryFindChild('Bucket') as s3.Bucket; 30 | bucket.applyRemovalPolicy(RemovalPolicy.DESTROY); 31 | 32 | new CfnOutput(this, 'ApiEndpoint', { value: urlShortener.apiEndpoint }); 33 | } 34 | } 35 | 36 | const app = new App(); 37 | new TestStack(app, 'url-shortener-integ', { 38 | env: { 39 | region: 'eu-west-1', 40 | }, 41 | }); 42 | app.synth(); 43 | -------------------------------------------------------------------------------- /test/url-shortener/url-shortener.test.ts: -------------------------------------------------------------------------------- 1 | import { App, Duration, Stack } from 'aws-cdk-lib'; 2 | import { Template } from 'aws-cdk-lib/assertions'; 3 | import * as apigateway from 'aws-cdk-lib/aws-apigateway'; 4 | import * as cognito from 'aws-cdk-lib/aws-cognito'; 5 | import * as ec2 from 'aws-cdk-lib/aws-ec2'; 6 | import * as iam from 'aws-cdk-lib/aws-iam'; 7 | import * as route53 from 'aws-cdk-lib/aws-route53'; 8 | import { UrlShortener } from '../../src'; 9 | 10 | let stack: Stack; 11 | let app: App; 12 | beforeEach(() => { 13 | app = new App(); 14 | stack = new Stack(app, 'Stack', { 15 | env: { region: 'eu-west-1' }, 16 | }); 17 | }); 18 | 19 | test('UrlShortener', () => { 20 | const hostedZone = new route53.HostedZone(stack, 'HostedZone', { zoneName: 'cstructs.com' }); 21 | 22 | new UrlShortener(stack, 'UrlShortener', { 23 | hostedZone, 24 | expiration: Duration.days(60), 25 | }); 26 | 27 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 28 | }); 29 | 30 | 31 | test('UrlShortener with API gateway endpoint', () => { 32 | const hostedZone = new route53.HostedZone(stack, 'HostedZone', { zoneName: 'cstructs.com' }); 33 | 34 | const vpc = new ec2.Vpc(stack, 'Vpc'); 35 | const apiGatewayEndpoint = new ec2.InterfaceVpcEndpoint(stack, 'ApiGatewayEndpoint', { 36 | service: ec2.InterfaceVpcEndpointAwsService.APIGATEWAY, 37 | vpc, 38 | }); 39 | 40 | new UrlShortener(stack, 'UrlShortener', { 41 | hostedZone, 42 | apiGatewayEndpoint, 43 | }); 44 | 45 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 46 | }); 47 | 48 | test('UrlShortener with record name', () => { 49 | const hostedZone = new route53.HostedZone(stack, 'HostedZone', { zoneName: 'cstructs.com' }); 50 | 51 | new UrlShortener(stack, 'UrlShortener', { 52 | hostedZone, 53 | recordName: 'short', 54 | }); 55 | 56 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 57 | }); 58 | 59 | test('UrlShortener with authorizer', () => { 60 | const hostedZone = new route53.HostedZone(stack, 'HostedZone', { zoneName: 'cstructs.com' }); 61 | 62 | new UrlShortener(stack, 'UrlShortener', { 63 | hostedZone, 64 | apiGatewayAuthorizer: new apigateway.CognitoUserPoolsAuthorizer(stack, 'Cognito', { 65 | cognitoUserPools: [new cognito.UserPool(stack, 'UserPool')], 66 | }), 67 | }); 68 | 69 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 70 | }); 71 | 72 | test('UrlShortener with CORS', () => { 73 | const hostedZone = new route53.HostedZone(stack, 'HostedZone', { zoneName: 'cstructs.com' }); 74 | 75 | new UrlShortener(stack, 'UrlShortener', { 76 | hostedZone, 77 | corsAllowOrigins: ['https://www.example.com'], 78 | }); 79 | 80 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 81 | }); 82 | 83 | test('UrlShortener with IAM authorization', () => { 84 | const hostedZone = new route53.HostedZone(stack, 'HostedZone', { zoneName: 'cstructs.com' }); 85 | 86 | const urlShortener = new UrlShortener(stack, 'UrlShortener', { 87 | hostedZone, 88 | iamAuthorization: true, 89 | }); 90 | 91 | const role = new iam.Role(stack, 'Role', { 92 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 93 | }); 94 | 95 | urlShortener.grantInvoke(role); 96 | 97 | expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); 98 | }); 99 | -------------------------------------------------------------------------------- /tsconfig.dev.json: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | { 3 | "compilerOptions": { 4 | "alwaysStrict": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "experimentalDecorators": true, 8 | "inlineSourceMap": true, 9 | "inlineSources": true, 10 | "lib": [ 11 | "es2019" 12 | ], 13 | "module": "CommonJS", 14 | "noEmitOnError": false, 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitAny": true, 17 | "noImplicitReturns": true, 18 | "noImplicitThis": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "resolveJsonModule": true, 22 | "strict": true, 23 | "strictNullChecks": true, 24 | "strictPropertyInitialization": true, 25 | "stripInternal": true, 26 | "target": "ES2019" 27 | }, 28 | "include": [ 29 | "src/**/*.ts", 30 | "test/**/*.ts", 31 | ".projenrc.js" 32 | ], 33 | "exclude": [ 34 | "node_modules" 35 | ] 36 | } 37 | --------------------------------------------------------------------------------