├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── README.md ├── package.json ├── prettier.config.js ├── src ├── addDomainToDistribution.js ├── createAppSyncApiKey.js ├── deployApigDomainDns.js ├── deployAppSyncApi.js ├── deployAppSyncApiKey.js ├── deployAppSyncDistribution.js ├── deployAppSyncResolvers.js ├── deployAppSyncSchema.js ├── deployCertificate.js ├── deployDistribution.js ├── deployDistributionDns.js ├── deployDistributionDomain.js ├── deployLambda.js ├── deployRole.js ├── deployStack.js ├── disableDistribution.js ├── getAccountId.js ├── getAppSyncResolversPolicy.js ├── getCloudWatchLogGroupArn.js ├── getDomainHostedZoneId.js ├── getElasticSearchArn.js ├── getLambdaArn.js ├── getMetrics.js ├── getRdsArn.js ├── getRole.js ├── getRoleArn.js ├── getTableArn.js ├── index.js ├── listAllAwsRegions.js ├── listAllCloudFormationStackResources.js ├── listAllCloudFormationStacksInARegion.js ├── listAllCloudFormationStacksInAllRegions.js ├── removeAppSyncApi.js ├── removeDistribution.js ├── removeLambda.js ├── removeRole.js ├── removeRolePolicies.js ├── removeStack.js ├── utils.js └── waitForStack.js └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_size = 2 11 | indent_style = space 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | node_modules -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['prettier'], 4 | plugins: ['import', 'prettier'], 5 | env: { 6 | es6: true, 7 | jest: true, 8 | node: true 9 | }, 10 | parser: 'babel-eslint', 11 | parserOptions: { 12 | ecmaVersion: 2018, 13 | sourceType: 'module', 14 | ecmaFeatures: { 15 | jsx: true 16 | } 17 | }, 18 | globals: { 19 | on: true // for the Socket file 20 | }, 21 | rules: { 22 | 'array-bracket-spacing': [ 23 | 'error', 24 | 'never', 25 | { 26 | objectsInArrays: false, 27 | arraysInArrays: false 28 | } 29 | ], 30 | 'arrow-parens': ['error', 'always'], 31 | 'arrow-spacing': ['error', { before: true, after: true }], 32 | 'comma-dangle': ['error', 'never'], 33 | curly: 'error', 34 | 'eol-last': 'error', 35 | 'func-names': 'off', 36 | 'id-length': [ 37 | 'error', 38 | { 39 | min: 2, 40 | max: 50, 41 | properties: 'never', 42 | exceptions: ['e', 'i', 'n', 't', 'x', 'y', 'z', '_', '$'] 43 | } 44 | ], 45 | 'no-alert': 'error', 46 | 'no-console': 'error', 47 | 'no-const-assign': 'error', 48 | 'no-else-return': 'error', 49 | 'no-empty': 'off', 50 | 'no-shadow': 'error', 51 | 'no-undef': 'error', 52 | 'no-unused-vars': 'error', 53 | 'no-use-before-define': 'error', 54 | 'no-useless-constructor': 'error', 55 | 'object-curly-newline': 'off', 56 | 'object-shorthand': 'off', 57 | 'id-length': 'off', 58 | 'prefer-const': 'error', 59 | 'prefer-destructuring': ['error', { object: true, array: false }], 60 | quotes: [ 61 | 'error', 62 | 'single', 63 | { 64 | allowTemplateLiterals: true, 65 | avoidEscape: true 66 | } 67 | ], 68 | semi: ['error', 'never'], 69 | 'spaced-comment': 'error', 70 | strict: ['error', 'never'], 71 | 'prettier/prettier': 'error' 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.sublime-project 3 | *.sublime-workspace 4 | *.log 5 | .serverless 6 | v8-compile-cache-* 7 | jest/* 8 | coverage 9 | testProjects/*/package-lock.json 10 | testProjects/*/yarn.lock 11 | .serverlessUnzipped 12 | node_modules 13 | .vscode/ 14 | .eslintcache 15 | dist 16 | .idea 17 | build/ 18 | .env* 19 | .cache* 20 | .serverless 21 | .serverless_nextjs 22 | .serverless_plugins 23 | env.js 24 | tmp 25 | package-lock.json 26 | yarn.lock 27 | tests 28 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | node_modules -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **📦 Archived - This repository is archived and preserved for reference only. No updates, issues, or pull requests will be accepted. If you have questions, please reach out to our support team.** 2 | 3 | --- 4 | 5 | # AWS SDK Extra 6 | 7 | The AWS SDK + a handful of extra convenience methods. 8 | 9 | ```js 10 | // require aws-sdk-extra, instead of the official aws-sdk 11 | const aws = require(`@serverless/aws-sdk-extra`) 12 | 13 | // initialize any service, as usual. 14 | const s3 = new aws.S3({ 15 | credentials: { accessKeyId: 'xxx', secretAccessKey: 'xxx' }, 16 | region: 'us-east-1' 17 | }) 18 | 19 | // initialize the Extras service for extra methods 20 | const extras = new aws.Extras({ 21 | credentials: { accessKeyId: 'xxx', secretAccessKey: 'xxx' }, 22 | region: 'us-east-1' 23 | }) 24 | 25 | // call some powerful extra methods. More info below. 26 | const certificate = await extras.deployCertificate(params) 27 | ``` 28 | 29 | # Reference 30 | 31 | - [deployDistributionDomain](#deployDistributionDomain) 32 | - [deployCertificate](#deployCertificate) 33 | - [deployDistributionDns](#deployDistributionDns) 34 | - [addDomainToDistribution](#addDomainToDistribution) 35 | - [getDomainHostedZoneId](#getDomainHostedZoneId) 36 | - [deployRole](#deployRole) 37 | - [removeRole](#removeRole) 38 | - [removeRolePolicies](#removeRolePolicies) 39 | - [deployLambda](#deployLambda) 40 | - [deployApigDomainDns](#deployApigDomainDns) 41 | - [deployAppSyncApi](#deployAppSyncApi) 42 | - [deployAppSyncSchema](#deployAppSyncSchema) 43 | - [deployAppSyncResolvers](#deployAppSyncResolvers) 44 | - [deployStack](#deployStack) 45 | - [removeStack](#removeStack) 46 | 47 | # deployDistributionDomain 48 | 49 | Deploys a CloudFront distribution domain by adding the domain to the distribution and deploying the certificate and DNS records. 50 | 51 | ```js 52 | const params = { 53 | domain: 'serverless.com', 54 | distributionId: 'xxx' 55 | } 56 | 57 | const { 58 | certificateArn, 59 | certificateStatus, 60 | domainHostedZoneId 61 | } = await extras.deployDistributionDomain(params) 62 | ``` 63 | 64 | # deployCertificate 65 | 66 | Deploys a free ACM certificate for the given domain. 67 | 68 | ```js 69 | const params = { 70 | domain: 'serverless.com' 71 | } 72 | 73 | const { certificateArn, certificateStatus, domainHostedZoneId } = await extras.deployCertificate( 74 | params 75 | ) 76 | ``` 77 | 78 | # deployDistributionDns 79 | 80 | Deploys a DNS records for a distribution domain. 81 | 82 | ```js 83 | const params = { 84 | domain: 'serverless.com', 85 | distributionUrl: 'xxx.cloudfront.net' 86 | } 87 | 88 | const { domainHostedZoneId } = await extras.deployDistributionDns(params) 89 | ``` 90 | 91 | # addDomainToDistribution 92 | 93 | Adds a domain or subdomain to a CloudFront Distribution. 94 | 95 | ```js 96 | const params = { 97 | domain: 'serverless.com', 98 | certificateArn: 'xxx:xxx', 99 | certificateStatus: 'ISSUED' 100 | } 101 | 102 | const { domainHostedZoneId } = await extras.addDomainToDistribution(params) 103 | ``` 104 | 105 | # getDomainHostedZoneId 106 | 107 | Fetches the hosted zone id for the given domain. 108 | 109 | ```js 110 | const params = { 111 | domain: 'serverless.com' 112 | } 113 | 114 | const { domainHostedZoneId } = await extras.getDomainHostedZoneId(params) 115 | ``` 116 | 117 | # deployRole 118 | 119 | Updates or creates the given role name with the given service & policy. You can specify an inline policy: 120 | 121 | ```js 122 | const params = { 123 | name: 'my-role', 124 | service: 'lambda.amazonaws.com', 125 | policy: [ 126 | { 127 | Effect: 'Allow', 128 | Action: ['sts:AssumeRole'], 129 | Resource: '*' 130 | }, 131 | { 132 | Effect: 'Allow', 133 | Action: ['logs:CreateLogGroup', 'logs:CreateLogStream'], 134 | Resource: '*' 135 | } 136 | ] 137 | } 138 | const { roleArn } = await extras.deployRole(params) 139 | ``` 140 | 141 | Or you can specify the policy as a maanged policy arn string: 142 | 143 | ```js 144 | const params = { 145 | name: 'my-role', 146 | service: 'lambda.amazonaws.com', 147 | policy: 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' 148 | } 149 | const { roleArn } = await extras.deployRole(params) 150 | ``` 151 | 152 | If you don't specify a policy property, an admin policy will be created by default. 153 | 154 | # removeRole 155 | 156 | Removes the given role and all its attached managed and inline policies. 157 | 158 | ```js 159 | const params = { 160 | name: 'my-role' 161 | } 162 | 163 | await extras.removeRole(params) 164 | ``` 165 | 166 | # removeRolePolicies 167 | 168 | Removes all attached managed and inline policies for the given role. 169 | 170 | ```js 171 | const params = { 172 | name: 'my-role' 173 | } 174 | 175 | await extras.removeRolePolicies(params) 176 | ``` 177 | 178 | # deployLambda 179 | 180 | Updates a lambda if it exists, otherwise creates a new one. 181 | 182 | ```js 183 | const params = { 184 | lambdaName: 'my-lambda', // required 185 | roleArn: 'aws:iam:role:arn:xxx', // required 186 | lambdaSrc: 'path/to/lambda/directory' // required. could also be a buffer of a zip file 187 | memory: 512 // optional, along with the other lambda config 188 | vpcConfig: // optional, specify a VPC 189 | securityGroupIds: 190 | - sg-xxx 191 | subnetIds: 192 | - subnet-xxx 193 | - subnet-xxx 194 | } 195 | 196 | const { lambdaArn, lambdaSize, lambdaSha } = await extras.deployLambda(params) 197 | ``` 198 | 199 | # deployApigDomainDns 200 | 201 | Deploys the DNS records for an Api Gateway V2 HTTP custom domain 202 | 203 | ```js 204 | const params = { 205 | domain: 'serverless.com', // required. The custom domain you'd like to configure. 206 | apigatewayHostedZoneId: 'qwertyuiop', // required. The regional hosted zone id of the APIG custom domain 207 | apigatewayDomainName: 'd-qwertyuiop.xxx.com' // required. The regional endpoint of the APIG custom domain 208 | } 209 | 210 | const { domainHostedZoneId } = await extras.deployApigDomainDns(params) 211 | ``` 212 | 213 | # deployAppSyncApi 214 | 215 | Updates or creates an AppSync API 216 | 217 | ```js 218 | const params = { 219 | apiName: 'my-api', 220 | apiId: 'xxx' // if provided, updates the API. If not provided, creates a new API 221 | } 222 | 223 | const { apiId, apiUrls } = await extras.deployAppSyncApi(params) 224 | ``` 225 | 226 | # deployAppSyncSchema 227 | 228 | Updates or creates an AppSync Schema 229 | 230 | ```js 231 | const params = { 232 | apiId: 'xxx', // the targeted api id 233 | schema: '...' // valid graphql schema 234 | } 235 | 236 | await extras.deployAppSyncApi(params) 237 | ``` 238 | 239 | # deployAppSyncResolvers 240 | 241 | Updates or creates AppSync Resolvers 242 | 243 | ```js 244 | const params = { 245 | apiId, 246 | roleName: 'my-role', // name of the role that provides access for these resources to the required resources 247 | resolvers: { 248 | Query: { 249 | getPost: { 250 | lambda: 'getPost' // name of the lambda function to use as a resolver for the getPost field 251 | } 252 | }, 253 | Mutation: { 254 | putPost: { 255 | lambda: 'putPost' 256 | } 257 | } 258 | } 259 | } 260 | 261 | await extras.deployAppSyncResolvers(params) 262 | ``` 263 | 264 | # deployStack 265 | 266 | Updates or creates a CloudFormation stack. 267 | 268 | ```js 269 | const inputs = { 270 | stackName: 'my-stack', // required 271 | template: { 272 | // required 273 | AWSTemplateFormatVersion: '2010-09-09', 274 | Description: 'Example Stack', 275 | Resources: { 276 | LogGroup: { 277 | Type: 'AWS::Logs::LogGroup', 278 | Properties: { 279 | LogGroupName: '/log/group/one', 280 | RetentionInDays: 14 281 | } 282 | } 283 | }, 284 | Outputs: { 285 | firstStackOutput: { 286 | Value: { 287 | 'Fn::GetAtt': ['LogGroup', 'Arn'] 288 | } 289 | }, 290 | secondStackOutput: { 291 | Value: { 292 | 'Fn::GetAtt': ['LogGroup', 'Arn'] 293 | } 294 | } 295 | } 296 | }, 297 | capabilities: ['CAPABILITY_IAM'], 298 | parameters: { 299 | firstParameter: 'value' 300 | }, 301 | role: 'arn:iam:xxx' 302 | } 303 | 304 | const outputs = await extras.deployStack(params) 305 | ``` 306 | 307 | # removeStack 308 | 309 | Removes a CloudFormation stack if it exists. 310 | 311 | ```js 312 | const prams = { 313 | stackName: 'my-stack' // name of the stack you want to remove 314 | } 315 | 316 | await extras.removeStack(params) 317 | ``` 318 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@serverless/aws-sdk-extra", 3 | "version": "1.2.7", 4 | "description": "The AWS SDK that includes with a handful of extra convenience methods.", 5 | "main": "src/index.js", 6 | "author": "Serverless, Inc.", 7 | "license": "MIT", 8 | "publishConfig": { 9 | "access": "public" 10 | }, 11 | "dependencies": { 12 | "adm-zip": "^0.4.14", 13 | "aws-sdk": "^2.614.0", 14 | "merge-deep": "^3.0.2", 15 | "moment": "^2.27.0", 16 | "ramda": "^0.26.1" 17 | }, 18 | "scripts": { 19 | "test": "jest ./tests/integration.test.js --testEnvironment node" 20 | }, 21 | "devDependencies": { 22 | "jest": "^25.1.0", 23 | "babel-eslint": "9.0.0", 24 | "eslint": "5.6.0", 25 | "eslint-config-prettier": "^3.6.0", 26 | "eslint-plugin-import": "^2.20.0", 27 | "eslint-plugin-prettier": "^3.1.0", 28 | "prettier": "^1.18.2" 29 | } 30 | } -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | printWidth: 100, 4 | semi: false, 5 | singleQuote: true, 6 | tabWidth: 2, 7 | trailingComma: 'none' 8 | } 9 | -------------------------------------------------------------------------------- /src/addDomainToDistribution.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const { getNakedDomain, shouldConfigureNakedDomain } = require('./utils') 3 | 4 | module.exports = async (config, params = {}) => { 5 | params.log = params.log || (() => { }) 6 | const { log } = params 7 | const nakedDomain = getNakedDomain(params.domain) 8 | const cf = new AWS.CloudFront(config) 9 | try { 10 | const updateDistributionParams = await cf 11 | .getDistributionConfig({ Id: params.distributionId }) 12 | .promise() 13 | 14 | // 2. then add this property 15 | updateDistributionParams.IfMatch = updateDistributionParams.ETag 16 | 17 | // 3. then delete this property 18 | delete updateDistributionParams.ETag 19 | 20 | // 4. then set this property 21 | updateDistributionParams.Id = params.distributionId 22 | 23 | // 5. then make our changes 24 | updateDistributionParams.DistributionConfig.Enabled = true 25 | 26 | // add domain and certificate config if certificate is valid and ISSUED 27 | if (params.certificateStatus === 'ISSUED') { 28 | log(`Adding "${nakedDomain}" certificate to CloudFront distribution`) 29 | updateDistributionParams.DistributionConfig.ViewerCertificate = { 30 | ACMCertificateArn: params.certificateArn, 31 | SSLSupportMethod: 'sni-only', 32 | MinimumProtocolVersion: 'TLSv1.1_2016', 33 | Certificate: params.certificateArn, 34 | CertificateSource: 'acm' 35 | } 36 | 37 | log(`Adding domain "${params.domain}" to CloudFront distribution`) 38 | updateDistributionParams.DistributionConfig.Aliases = { 39 | Quantity: 1, 40 | Items: [params.domain] 41 | } 42 | 43 | if (shouldConfigureNakedDomain(params.domain)) { 44 | log(`Adding domain "${nakedDomain}" to CloudFront distribution`) 45 | updateDistributionParams.DistributionConfig.Aliases.Quantity = 2 46 | updateDistributionParams.DistributionConfig.Aliases.Items.push(nakedDomain) 47 | } 48 | } 49 | // 6. then finally update! 50 | const res = await cf.updateDistribution(updateDistributionParams).promise() 51 | 52 | return { 53 | distributionId: res.Distribution.Id, 54 | distributionArn: res.Distribution.ARN, 55 | distributionUrl: res.Distribution.DomainName 56 | } 57 | } catch (e) { 58 | if (e.message.includes('One or more of the CNAMEs')) { 59 | throw new Error( 60 | `The domain "${params.domain}" is already in use by another website or CloudFront Distribution.` 61 | ) 62 | } 63 | 64 | throw e 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/createAppSyncApiKey.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | 3 | module.exports = async (config, params) => { 4 | if (!params.apiId) { 5 | throw new Error(`Missing "apiId" param.`) 6 | } 7 | 8 | const appSync = new AWS.AppSync(config) 9 | 10 | // set default expiration to be after 1 year minus 1 day 11 | // instead of the aws default of 7 days. 12 | // aws max is 1 year, so we gotta take a day out 13 | const nowInSeconds = Date.now() / 1000 14 | const oneYearInSeconds = 31556952 15 | const oneDayInSeconds = 86400 16 | const defaultExpiration = nowInSeconds + oneYearInSeconds - oneDayInSeconds 17 | 18 | var createApiKeyParams = { 19 | apiId: params.apiId, 20 | description: params.description || '', 21 | expires: params.expires || defaultExpiration 22 | } 23 | 24 | const { 25 | apiKey: { id } 26 | } = await appSync.createApiKey(createApiKeyParams).promise() 27 | 28 | return { apiKey: id } 29 | } 30 | -------------------------------------------------------------------------------- /src/deployApigDomainDns.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const getDomainHostedZoneId = require('./getDomainHostedZoneId') 3 | 4 | module.exports = async (config, params = {}) => { 5 | params.log = params.log || (() => { }) 6 | const { log, domain, apigatewayHostedZoneId, apigatewayDomainName } = params 7 | const domainHostedZoneId = params.domainHostedZoneId || (await getDomainHostedZoneId(config, params)) 8 | 9 | const route53 = new AWS.Route53(config) 10 | 11 | log(`Configuring Api Gateway DNS records for domain "${domain}"`) 12 | 13 | const dnsRecordParams = { 14 | HostedZoneId: domainHostedZoneId, 15 | ChangeBatch: { 16 | Changes: [ 17 | { 18 | Action: 'UPSERT', 19 | ResourceRecordSet: { 20 | Name: domain, 21 | Type: 'A', 22 | AliasTarget: { 23 | HostedZoneId: apigatewayHostedZoneId, 24 | DNSName: apigatewayDomainName, 25 | EvaluateTargetHealth: false 26 | } 27 | } 28 | } 29 | ] 30 | } 31 | } 32 | 33 | await route53.changeResourceRecordSets(dnsRecordParams).promise() 34 | 35 | return { domainHostedZoneId } 36 | } 37 | -------------------------------------------------------------------------------- /src/deployAppSyncApi.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | 3 | const setAuthConfig = (config, params, createUpdateParams) => { 4 | if (!params.auth || params.auth === 'apiKey') { 5 | // api key auth 6 | createUpdateParams.authenticationType = 'API_KEY' 7 | } else if (params.auth === 'awsIam' || params.auth === 'iam') { 8 | // iam auth 9 | createUpdateParams.authenticationType = 'AWS_IAM' 10 | } else if (params.auth.userPoolId) { 11 | createUpdateParams.authenticationType = 'AMAZON_COGNITO_USER_POOLS' 12 | createUpdateParams.userPoolConfig = { 13 | // cognito auth config 14 | userPoolId: params.auth.userPoolId, 15 | defaultAction: params.auth.defaultAction || 'ALLOW', 16 | awsRegion: params.auth.region || config.region || 'us-east-1', 17 | appIdClientRegex: params.auth.appIdClientRegex 18 | } 19 | } else if (params.auth.issuer) { 20 | createUpdateParams.authenticationType = 'OPENID_CONNECT' 21 | // open id auth config 22 | createUpdateParams.openIDConnectConfig = { 23 | // cognito auth config 24 | issuer: params.auth.issuer, 25 | authTTL: params.auth.authTTL, 26 | clientId: params.auth.clientId, 27 | iatTTL: params.auth.iatTTL 28 | } 29 | } else { 30 | // set api key for any other case 31 | createUpdateParams.authenticationType = 'API_KEY' 32 | } 33 | 34 | return createUpdateParams 35 | } 36 | 37 | const createAppSyncApi = async (config, params) => { 38 | const appSync = new AWS.AppSync(config) 39 | 40 | let createGraphqlApiParams = { 41 | name: params.apiName 42 | } 43 | 44 | createGraphqlApiParams = setAuthConfig(config, params, createGraphqlApiParams) 45 | 46 | const { graphqlApi } = await appSync.createGraphqlApi(createGraphqlApiParams).promise() 47 | 48 | return graphqlApi 49 | } 50 | 51 | const updateAppSyncApi = async (config, params) => { 52 | const appSync = new AWS.AppSync(config) 53 | 54 | let updateGraphqlApiparams = { 55 | apiId: params.apiId, 56 | name: params.apiName 57 | } 58 | 59 | updateGraphqlApiparams = setAuthConfig(config, params, updateGraphqlApiparams) 60 | 61 | const { graphqlApi } = await appSync.updateGraphqlApi(updateGraphqlApiparams).promise() 62 | 63 | return graphqlApi 64 | } 65 | 66 | module.exports = async (config, params) => { 67 | if (!params.apiName) { 68 | throw new Error(`Missing "apiName" param.`) 69 | } 70 | 71 | try { 72 | const { apiId, arn, uris } = await updateAppSyncApi(config, params) 73 | return { apiId, apiArn: arn, apiUrls: uris } 74 | } catch (e) { 75 | if ( 76 | e.code === 'NotFoundException' || 77 | e.message.includes(`Missing required key 'apiId' in params`) 78 | ) { 79 | const { apiId, arn, uris } = await createAppSyncApi(config, params) 80 | return { apiId, apiArn: arn, apiUrls: uris } 81 | } 82 | throw e 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/deployAppSyncApiKey.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | 3 | const createAppSyncApiKey = require('./createAppSyncApiKey') 4 | 5 | module.exports = async (config, params) => { 6 | if (!params.apiId) { 7 | throw new Error(`Missing "apiId" param.`) 8 | } 9 | 10 | const appSync = new AWS.AppSync(config) 11 | 12 | const createAppSyncApiKeyParams = { 13 | apiId: params.apiId, 14 | description: params.description, 15 | expires: params.expires 16 | } 17 | 18 | if (!params.apiKey) { 19 | return createAppSyncApiKey(config, createAppSyncApiKeyParams) 20 | } 21 | 22 | const listApiKeysParams = { 23 | apiId: params.apiId 24 | } 25 | 26 | const listApiKeysRes = await appSync.listApiKeys(listApiKeysParams).promise() 27 | 28 | const apiKeyExists = listApiKeysRes.apiKeys.find((apiKey) => apiKey.id === params.apiKey) 29 | 30 | if (apiKeyExists) { 31 | return { apiKey: apiKeyExists.id } 32 | } 33 | 34 | return createAppSyncApiKey(config, createAppSyncApiKeyParams) 35 | } 36 | -------------------------------------------------------------------------------- /src/deployAppSyncDistribution.js: -------------------------------------------------------------------------------- 1 | const deployDistribution = require('./deployDistribution') 2 | 3 | module.exports = async (config, params) => { 4 | const { domain, apiId, apiUrl } = params 5 | 6 | if (!apiId) { 7 | throw new Error(`Missing "apiId" param.`) 8 | } 9 | 10 | if (!apiUrl) { 11 | throw new Error(`Missing "apiUrl" param.`) 12 | } 13 | 14 | const deployDistributionParams = { 15 | domain, 16 | Origins: { 17 | Quantity: 1, 18 | Items: [ 19 | { 20 | Id: apiId, // required 21 | DomainName: apiUrl.replace('https://', '').replace('/graphql', ''), // required 22 | OriginPath: '', // required 23 | CustomOriginConfig: { 24 | HTTPPort: 80, 25 | HTTPSPort: 443, 26 | OriginKeepaliveTimeout: 5, 27 | OriginProtocolPolicy: 'https-only', 28 | OriginReadTimeout: 30, 29 | OriginSslProtocols: { 30 | Items: ['SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2'], 31 | Quantity: 4 32 | } 33 | } 34 | } 35 | ] 36 | }, 37 | DefaultCacheBehavior: { 38 | AllowedMethods: { 39 | Quantity: 7, 40 | Items: ['HEAD', 'GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'] 41 | }, 42 | TrustedSigners: { 43 | // required 44 | Enabled: false, 45 | Quantity: 0, 46 | Items: [] 47 | }, 48 | ViewerProtocolPolicy: 'redirect-to-https', // required 49 | MinTTL: 0, // required 50 | DefaultTTL: 86400, 51 | MaxTTL: 31536000, 52 | SmoothStreaming: false, 53 | TargetOriginId: apiId, // required 54 | ForwardedValues: { 55 | QueryString: false, // required 56 | Cookies: { 57 | // required 58 | Forward: 'none' 59 | } 60 | } 61 | } 62 | } 63 | 64 | if (params.distributionId) { 65 | deployDistributionParams.distributionId = params.distributionId 66 | } 67 | 68 | const distribution = await deployDistribution(config, deployDistributionParams) 69 | 70 | return distribution 71 | } 72 | -------------------------------------------------------------------------------- /src/deployAppSyncResolvers.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const { memoizeWith, omit } = require('ramda') 3 | const crypto = require('crypto') 4 | const { sleep } = require('./utils') 5 | const getRoleArn = require('./getRoleArn') 6 | const getLambdaArn = require('./getLambdaArn') 7 | 8 | const listResolvers = async (config, params) => { 9 | const appSync = new AWS.AppSync(config) 10 | 11 | try { 12 | const res = await appSync.listResolvers(params).promise() 13 | 14 | // all we need is the field name 15 | return res.resolvers.reduce((resolvers, resolver) => { 16 | resolvers[resolver.fieldName] = resolver 17 | return resolvers 18 | }, {}) 19 | } catch (e) { 20 | if (e.code === 'NotFoundException') { 21 | // if type (ie. Mutation) not found, just return an empty object 22 | return {} 23 | } 24 | throw e 25 | } 26 | } 27 | 28 | const getExistingResolvers = async (config, { apiId }) => { 29 | const promises = [ 30 | // todo any other types? 31 | listResolvers(config, { apiId, typeName: 'Query' }), 32 | listResolvers(config, { apiId, typeName: 'Mutation' }) 33 | ] 34 | 35 | const res = await Promise.all(promises) 36 | 37 | return { 38 | Query: res[0], 39 | Mutation: res[1] 40 | } 41 | } 42 | 43 | const updateAppSyncResolver = async (config, params) => { 44 | const appSync = new AWS.AppSync(config) 45 | try { 46 | const updateResolverRes = await appSync.updateResolver(params).promise() 47 | return updateResolverRes 48 | } catch (e) { 49 | if ( 50 | e.code === 'NotFoundException' && 51 | e.message.includes('Type') && 52 | e.message.includes('not found') 53 | ) { 54 | // let's not mask the error for now. Maybe it should be done at the component level 55 | // throw new Error(`Resolver type "${params.typeName}" was not found in your GraphQL schema.`) 56 | throw e 57 | } else { 58 | throw e 59 | } 60 | } 61 | } 62 | 63 | const createAppSyncResolver = async (config, params) => { 64 | const appSync = new AWS.AppSync(config) 65 | try { 66 | const createResolverRes = await appSync.createResolver(params).promise() 67 | return createResolverRes 68 | } catch (e) { 69 | if ( 70 | e.code === 'NotFoundException' && 71 | e.message.includes('Type') && 72 | e.message.includes('not found') 73 | ) { 74 | // let's not mask the error for now. Maybe it should be done at the component level 75 | // throw new Error(`Resolver type "${params.typeName}" was not found in your GraphQL schema.`) 76 | throw e 77 | } else { 78 | throw e 79 | } 80 | } 81 | } 82 | 83 | const getDataSourceName = (name) => { 84 | // todo this won't scale. usersposts & users-posts would end up being the same 85 | return name.replace(/[^a-z0-9]/gi, '') // data source name must be alphanumeric 86 | } 87 | 88 | const deployAppSyncDataSource = async (config, params) => { 89 | const appSync = new AWS.AppSync(config) 90 | 91 | const dataSourceParams = { 92 | apiId: params.apiId, 93 | serviceRoleArn: await getRoleArn(config, { roleName: params.roleName }) 94 | } 95 | 96 | if (params.lambda) { 97 | // lambda config 98 | dataSourceParams.name = getDataSourceName(params.lambda) 99 | dataSourceParams.type = 'AWS_LAMBDA' 100 | dataSourceParams.lambdaConfig = { 101 | lambdaFunctionArn: await getLambdaArn(config, { lambdaName: params.lambda }) 102 | } 103 | } else if (params.table) { 104 | // dynamodb config 105 | dataSourceParams.name = getDataSourceName(params.table) 106 | dataSourceParams.type = 'AMAZON_DYNAMODB' 107 | dataSourceParams.dynamodbConfig = { 108 | awsRegion: params.tableRegion || config.region, 109 | tableName: params.table 110 | } 111 | 112 | if (params.ttl) { 113 | dataSourceParams.dynamodbConfig.deltaSyncConfig = 114 | dataSourceParams.dynamodbConfig.deltaSyncConfig || {} 115 | dataSourceParams.dynamodbConfig.deltaSyncConfig.baseTableTTL = params.ttl 116 | } 117 | 118 | if (params.syncTable) { 119 | dataSourceParams.dynamodbConfig.deltaSyncConfig = 120 | dataSourceParams.dynamodbConfig.deltaSyncConfig || {} 121 | dataSourceParams.dynamodbConfig.deltaSyncConfig.deltaSyncTableName = params.syncTable 122 | } 123 | 124 | if (params.syncTableTtl) { 125 | dataSourceParams.dynamodbConfig.deltaSyncConfig = 126 | dataSourceParams.dynamodbConfig.deltaSyncConfig || {} 127 | dataSourceParams.dynamodbConfig.deltaSyncConfig.deltaSyncTableTTL = params.syncTableTtl 128 | } 129 | } else if (params.authorizationConfig) { 130 | dataSourceParams.name = params.name // required. todo how to auto generate endpoint?! 131 | dataSourceParams.type = 'HTTP' 132 | dataSourceParams.httpConfig = { 133 | endpoint: params.endpoint, // required 134 | authorizationConfig: { 135 | authorizationType: params.authorization, // required. 136 | awsIamConfig: { 137 | signingRegion: params.signingRegion, 138 | signingServiceName: params.signingServiceName 139 | } 140 | } 141 | } 142 | } else if (params.endpoint) { 143 | // elasticsearch config 144 | dataSourceParams.name = params.name // required. todo how to auto generate endpoint?! 145 | dataSourceParams.type = 'AMAZON_ELASTICSEARCH' 146 | dataSourceParams.elasticsearchConfig = { 147 | awsRegion: params.endpointRegion || config.region, 148 | endpoint: params.endpoint 149 | } 150 | } else if (params.relationalDatabaseSourceType) { 151 | // relational data base config 152 | dataSourceParams.name = params.name || getDataSourceName(params.database) 153 | dataSourceParams.type = 'RELATIONAL_DATABASE' 154 | dataSourceParams.relationalDatabaseConfig = { 155 | relationalDatabaseSourceType: params.relationalDatabaseSourceType || 'RDS_HTTP_ENDPOINT', 156 | rdsHttpEndpointConfig: { 157 | awsRegion: params.endpointRegion || config.region, 158 | awsSecretStoreArn: params.awsSecretStoreArn, 159 | databaseName: params.database, 160 | dbClusterIdentifier: params.dbClusterIdentifier, 161 | schema: params.schema 162 | } 163 | } 164 | } else { 165 | throw new Error(`Please specify a data source for resolver "${params.type}.${params.field}"`) 166 | } 167 | 168 | try { 169 | const { 170 | dataSource: { dataSourceArn, name } 171 | } = await appSync.updateDataSource(dataSourceParams).promise() 172 | 173 | return { dataSourceArn, dataSourceName: name } 174 | } catch (e) { 175 | if (e.code === 'NotFoundException') { 176 | try { 177 | const { 178 | dataSource: { dataSourceArn, name } 179 | } = await appSync.createDataSource(dataSourceParams).promise() 180 | return { dataSourceArn, dataSourceName: name } 181 | } catch (createError) { 182 | if ( 183 | createError.code === 'BadRequestException' && 184 | createError.message.includes('Data source with name') 185 | ) { 186 | const { 187 | dataSource: { dataSourceArn, name } 188 | } = await appSync.updateDataSource(dataSourceParams).promise() 189 | 190 | return { dataSourceArn, dataSourceName: name } 191 | } 192 | throw createError 193 | } 194 | } 195 | 196 | throw e 197 | } 198 | } 199 | 200 | const deployAppSyncDataSourceCached = memoizeWith((config, params) => { 201 | // for multiple resolvers using the same data source, we just need 202 | // to deploy the data source once. This function uses a memoized/cached 203 | // version of the function if called twice 204 | const hash = crypto 205 | .createHash('sha256') 206 | .update(JSON.stringify(omit(['type', 'field', 'request', 'response'], params))) 207 | .digest('hex') 208 | return hash 209 | }, deployAppSyncDataSource) 210 | 211 | const deployAppSyncResolver = async (config, params) => { 212 | const { dataSourceName } = await deployAppSyncDataSource(config, params) 213 | const resolverParams = { 214 | apiId: params.apiId, 215 | kind: 'UNIT', 216 | typeName: params.type, 217 | fieldName: params.field, 218 | dataSourceName: dataSourceName, 219 | requestMappingTemplate: 220 | params.request || 221 | `{ "version": "2017-02-28", "operation": "Invoke", "payload": $util.toJson($context) }`, 222 | responseMappingTemplate: params.response || '$util.toJson($context.result)' 223 | } 224 | try { 225 | const updateResolverRes = await updateAppSyncResolver(config, resolverParams) 226 | return updateResolverRes 227 | } catch (e) { 228 | if (e.code === 'NotFoundException' && e.message.includes(`No resolver found`)) { 229 | const createResolverRes = await createAppSyncResolver(config, resolverParams) 230 | return createResolverRes 231 | } 232 | throw e 233 | } 234 | } 235 | 236 | const getExistingDataSourcesNames = async (config, { apiId }) => { 237 | const appSync = new AWS.AppSync(config) 238 | 239 | const listDataSourcesRes = await appSync.listDataSources({ apiId }).promise() 240 | 241 | return listDataSourcesRes.dataSources.map((dataSource) => dataSource.name) 242 | } 243 | 244 | const getDataSourcesNames = (resolvers) => { 245 | const dataSourcesNames = [] 246 | 247 | for (const type in resolvers) { 248 | for (const field in resolvers[type]) { 249 | const { name, lambda, table, database } = resolvers[type][field] 250 | const dataSourceName = getDataSourceName(name || lambda || table || database) 251 | dataSourcesNames.push(dataSourceName) 252 | } 253 | } 254 | 255 | return dataSourcesNames 256 | } 257 | 258 | const deployAppSyncResolvers = async (config, params) => { 259 | try { 260 | const { apiId, roleName, resolvers } = params 261 | 262 | if (!apiId) { 263 | throw new Error(`Missing "apiId" param.`) 264 | } 265 | 266 | if (!roleName) { 267 | throw new Error(`Missing "roleName" param.`) 268 | } 269 | 270 | if (typeof resolvers !== 'object') { 271 | throw new Error(`"resolvers" param is missing or is not an object.`) 272 | } 273 | 274 | const existingResources = await Promise.all([ 275 | getExistingResolvers(config, params), 276 | getExistingDataSourcesNames(config, params) 277 | ]) 278 | const existingResolvers = existingResources[0] 279 | const existingDataSourcesNames = existingResources[1] 280 | 281 | const promises = [] 282 | 283 | for (const type in resolvers) { 284 | if (typeof resolvers[type] !== 'object') { 285 | throw new Error(`"resolvers.${type}" must be an object.`) 286 | } 287 | 288 | for (const field in resolvers[type]) { 289 | if (typeof resolvers[type][field] !== 'object') { 290 | throw new Error(`"resolvers.${type}.${field}" must be an object.`) 291 | } 292 | 293 | const resolverParams = { 294 | ...resolvers[type][field], 295 | apiId, 296 | roleName, 297 | type: type, 298 | field: field 299 | } 300 | promises.push(deployAppSyncResolver(config, resolverParams)) 301 | } 302 | } 303 | 304 | for (const type in existingResolvers) { 305 | for (const field in existingResolvers[type]) { 306 | if (!resolvers[type] || !resolvers[type][field]) { 307 | const appSync = new AWS.AppSync(config) 308 | const deleteAppSyncResolverParams = { 309 | apiId, 310 | fieldName: field, 311 | typeName: type 312 | } 313 | promises.push(appSync.deleteResolver(deleteAppSyncResolverParams).promise()) 314 | } 315 | } 316 | } 317 | 318 | await Promise.all(promises) 319 | 320 | const dataSourcesNames = getDataSourcesNames(resolvers) 321 | 322 | const removeOutdatedDataSources = [] 323 | for (const existingDataSourceName of existingDataSourcesNames) { 324 | if (!dataSourcesNames.includes(existingDataSourceName)) { 325 | const appSync = new AWS.AppSync(config) 326 | const deleteDataSourceParams = { 327 | apiId, 328 | name: existingDataSourceName 329 | } 330 | removeOutdatedDataSources.push(appSync.deleteDataSource(deleteDataSourceParams).promise()) 331 | } 332 | } 333 | 334 | await Promise.all(removeOutdatedDataSources) 335 | } catch (e) { 336 | if ( 337 | e.code === 'ConcurrentModificationException' && 338 | e.message.includes('Schema is currently being altered') 339 | ) { 340 | await sleep(1000) 341 | const deployAppSyncResolversRes = await deployAppSyncResolvers(config, params) 342 | return deployAppSyncResolversRes 343 | } 344 | throw e 345 | } 346 | } 347 | 348 | module.exports = deployAppSyncResolvers 349 | -------------------------------------------------------------------------------- /src/deployAppSyncSchema.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const { sleep } = require('./utils') 3 | 4 | // todo checksum 5 | const schemaCreation = async (config, params) => { 6 | const appSync = new AWS.AppSync(config) 7 | const { status, details } = await appSync 8 | .getSchemaCreationStatus({ apiId: params.apiId }) 9 | .promise() 10 | 11 | if (status === 'PROCESSING') { 12 | // retry if still processing 13 | await sleep(1000) 14 | return schemaCreation(config, params) 15 | } else if (status === 'SUCCESS') { 16 | // return if success 17 | return status 18 | } else if (status === 'FAILED' && details.includes('Internal Failure while saving the schema')) { 19 | // this error usually happens when the schema is in valid 20 | throw new Error(`Failed to save the schema. Please make sure it is a valid GraphQL schema.`) 21 | } 22 | 23 | // throw error for any other unsupported status 24 | throw new Error(`AppSync schema status: ${status} - ${details}`) 25 | } 26 | 27 | module.exports = async (config, params = {}) => { 28 | if (!params.apiId) { 29 | throw new Error(`Missing "appId" param.`) 30 | } 31 | 32 | if (!params.schema) { 33 | throw new Error(`Missing "schema" param.`) 34 | } 35 | 36 | const appSync = new AWS.AppSync(config) 37 | 38 | await appSync 39 | .startSchemaCreation({ 40 | apiId: params.apiId, 41 | definition: Buffer.from(params.schema) 42 | }) 43 | .promise() 44 | 45 | const status = await schemaCreation(config, params) 46 | 47 | return { status } 48 | } 49 | -------------------------------------------------------------------------------- /src/deployCertificate.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const { sleep, getNakedDomain } = require('./utils') 3 | const getDomainHostedZoneId = require('./getDomainHostedZoneId') 4 | 5 | const getCertificateArnByDomain = async (acm, nakedDomain) => { 6 | const listRes = await acm.listCertificates().promise() 7 | const certificate = listRes.CertificateSummaryList.find((cert) => cert.DomainName === nakedDomain) 8 | return certificate && certificate.CertificateArn ? certificate.CertificateArn : null 9 | } 10 | 11 | const getCertificateValidationRecord = (certificate, domain) => { 12 | if (!certificate.DomainValidationOptions) { 13 | return null 14 | } 15 | const domainValidationOption = certificate.DomainValidationOptions.find( 16 | (option) => option.DomainName === domain 17 | ) 18 | 19 | return domainValidationOption.ResourceRecord 20 | } 21 | 22 | const describeCertificateByArn = async (acm, certificateArn, nakedDomain) => { 23 | const res = await acm.describeCertificate({ CertificateArn: certificateArn }).promise() 24 | const certificate = res && res.Certificate ? res.Certificate : null 25 | 26 | if ( 27 | certificate.Status === 'PENDING_VALIDATION' && 28 | !getCertificateValidationRecord(certificate, nakedDomain) 29 | ) { 30 | await sleep(1000) 31 | return describeCertificateByArn(acm, certificateArn, nakedDomain) 32 | } 33 | 34 | return certificate 35 | } 36 | 37 | module.exports = async (config, params = {}) => { 38 | params.log = params.log || (() => { }) 39 | const { log } = params 40 | const nakedDomain = getNakedDomain(params.domain) 41 | const wildcardSubDomain = `*.${nakedDomain}` 42 | const { domainHostedZoneId } = await getDomainHostedZoneId(config, params) 43 | 44 | const acm = new AWS.ACM(config) 45 | const route53 = new AWS.Route53(config) 46 | 47 | const certificateParams = { 48 | DomainName: nakedDomain, 49 | SubjectAlternativeNames: [nakedDomain, wildcardSubDomain], 50 | ValidationMethod: 'DNS' 51 | } 52 | 53 | log(`Checking if a certificate for the ${nakedDomain} domain exists`) 54 | let certificateArn = await getCertificateArnByDomain(acm, nakedDomain) 55 | 56 | if (!certificateArn) { 57 | log(`Certificate for the ${nakedDomain} domain does not exist. Creating...`) 58 | certificateArn = (await acm.requestCertificate(certificateParams).promise()).CertificateArn 59 | } 60 | 61 | const certificate = await describeCertificateByArn(acm, certificateArn, nakedDomain) 62 | 63 | log(`Certificate for ${nakedDomain} is in a "${certificate.Status}" status`) 64 | 65 | if (certificate.Status === 'PENDING_VALIDATION') { 66 | const certificateValidationRecord = getCertificateValidationRecord(certificate, nakedDomain) 67 | // only validate if domain/hosted zone is found in this account 68 | if (domainHostedZoneId) { 69 | log(`Validating the certificate for the ${nakedDomain} domain.`) 70 | 71 | const recordParams = { 72 | HostedZoneId: domainHostedZoneId, 73 | ChangeBatch: { 74 | Changes: [ 75 | { 76 | Action: 'UPSERT', 77 | ResourceRecordSet: { 78 | Name: certificateValidationRecord.Name, 79 | Type: certificateValidationRecord.Type, 80 | TTL: 300, 81 | ResourceRecords: [ 82 | { 83 | Value: certificateValidationRecord.Value 84 | } 85 | ] 86 | } 87 | } 88 | ] 89 | } 90 | } 91 | await route53.changeResourceRecordSets(recordParams).promise() 92 | log( 93 | `Your certificate was created and is being validated. It may take a few mins to validate.` 94 | ) 95 | log( 96 | `Please deploy again after few mins to use your newly validated certificate and activate your domain.` 97 | ) 98 | } else { 99 | // if domain is not in account, let the user validate manually 100 | log( 101 | `Certificate for the ${nakedDomain} domain was created, but not validated. Please validate it manually.` 102 | ) 103 | log(`Certificate Validation Record Name: ${certificateValidationRecord.Name} `) 104 | log(`Certificate Validation Record Type: ${certificateValidationRecord.Type} `) 105 | log(`Certificate Validation Record Value: ${certificateValidationRecord.Value} `) 106 | } 107 | } 108 | 109 | return { 110 | domainHostedZoneId, 111 | certificateArn, 112 | certificateStatus: certificate.Status 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/deployDistribution.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const { mergeDeep } = require('./utils') 3 | const deployCertificate = require('./deployCertificate') 4 | const deployDistributionDns = require('./deployDistributionDns') 5 | 6 | const createDistribution = async (config, params = {}) => { 7 | const cf = new AWS.CloudFront(config) 8 | 9 | delete params.distributionId 10 | 11 | params.Enabled = params.Enabled || true 12 | params.Comment = params.Comment || '' 13 | params.CallerReference = params.CallerReference || String(Date.now()) 14 | 15 | const createDistributionParams = { 16 | DistributionConfig: { ...params } 17 | } 18 | 19 | if (params.certificateStatus === 'ISSUED') { 20 | createDistributionParams.DistributionConfig.Aliases = { 21 | Quantity: 1, 22 | Items: [params.domain] 23 | } 24 | createDistributionParams.DistributionConfig.ViewerCertificate = { 25 | ACMCertificateArn: params.certificateArn, 26 | SSLSupportMethod: 'sni-only', 27 | MinimumProtocolVersion: 'TLSv1.1_2016', 28 | Certificate: params.certificateArn, 29 | CertificateSource: 'acm' 30 | } 31 | } 32 | 33 | delete createDistributionParams.DistributionConfig.domain 34 | delete createDistributionParams.DistributionConfig.certificateArn 35 | delete createDistributionParams.DistributionConfig.certificateStatus 36 | delete createDistributionParams.DistributionConfig.domainHostedZoneId 37 | 38 | const res = await cf.createDistribution(createDistributionParams).promise() 39 | 40 | return { 41 | distributionId: res.Distribution.Id, 42 | distributionArn: res.Distribution.ARN, 43 | distributionUrl: res.Distribution.DomainName 44 | } 45 | } 46 | 47 | const updateDistribution = async (config, params = {}) => { 48 | const cf = new AWS.CloudFront(config) 49 | 50 | try { 51 | const updateDistributionParams = await cf 52 | .getDistributionConfig({ Id: params.distributionId }) 53 | .promise() 54 | 55 | // 2. then add this property 56 | updateDistributionParams.IfMatch = updateDistributionParams.ETag 57 | 58 | // 3. then delete this property 59 | delete updateDistributionParams.ETag 60 | 61 | // 4. then set this property 62 | updateDistributionParams.Id = params.distributionId 63 | 64 | // 5. then make our changes 65 | updateDistributionParams.DistributionConfig.Enabled = params.Enabled === false ? false : true 66 | 67 | if (params.certificateStatus === 'ISSUED') { 68 | updateDistributionParams.DistributionConfig.Aliases = { 69 | Quantity: 1, 70 | Items: [params.domain] 71 | } 72 | updateDistributionParams.DistributionConfig.ViewerCertificate = { 73 | ACMCertificateArn: params.certificateArn, 74 | SSLSupportMethod: 'sni-only', 75 | MinimumProtocolVersion: 'TLSv1.1_2016', 76 | Certificate: params.certificateArn, 77 | CertificateSource: 'acm' 78 | } 79 | } 80 | 81 | // these cannot exist in an update operation 82 | delete params.Origins 83 | delete params.CallerReference 84 | 85 | // todo this might not scale 86 | updateDistributionParams.DistributionConfig = mergeDeep( 87 | updateDistributionParams.DistributionConfig, 88 | params 89 | ) 90 | 91 | // make sure aliases match the definition 92 | // deep merging causes a mix 93 | if (!params.domain) { 94 | updateDistributionParams.DistributionConfig.Aliases = params.Aliases 95 | } 96 | 97 | // these are invalid CF parameters 98 | delete updateDistributionParams.DistributionConfig.distributionId 99 | delete updateDistributionParams.DistributionConfig.domain 100 | delete updateDistributionParams.DistributionConfig.certificateArn 101 | delete updateDistributionParams.DistributionConfig.certificateStatus 102 | delete updateDistributionParams.DistributionConfig.domainHostedZoneId 103 | 104 | // 6. then finally update! 105 | const res = await cf.updateDistribution(updateDistributionParams).promise() 106 | 107 | return { 108 | distributionId: res.Distribution.Id, 109 | distributionArn: res.Distribution.ARN, 110 | distributionUrl: res.Distribution.DomainName 111 | } 112 | } catch (e) { 113 | if (e.code === 'NoSuchDistribution') { 114 | const res = await createDistribution(config, params) 115 | return res 116 | } 117 | throw e 118 | } 119 | } 120 | 121 | module.exports = async (config, params = {}) => { 122 | const { domain } = params 123 | 124 | if (domain) { 125 | const deployCertificateParams = { 126 | domain 127 | } 128 | 129 | const res = await deployCertificate(config, deployCertificateParams) 130 | params.certificateArn = res.certificateArn // eslint-disable-line 131 | params.domainHostedZoneId = res.domainHostedZoneId // eslint-disable-line 132 | params.certificateStatus = res.certificateStatus // eslint-disable-line 133 | } 134 | 135 | let distribution 136 | if (params.distributionId) { 137 | distribution = await updateDistribution(config, params) 138 | } else { 139 | distribution = await createDistribution(config, params) 140 | } 141 | 142 | if (domain) { 143 | const deployDistributionDnsParams = { 144 | domain, 145 | distributionUrl: distribution.distributionUrl, 146 | domainHostedZoneId: params.domainHostedZoneId 147 | } 148 | 149 | await deployDistributionDns(config, deployDistributionDnsParams) 150 | 151 | distribution.certificateArn = params.certificateArn 152 | distribution.certificateStatus = params.certificateStatus 153 | distribution.certificateStatus = params.certificateStatus 154 | } 155 | 156 | return distribution 157 | } 158 | -------------------------------------------------------------------------------- /src/deployDistributionDns.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const { getNakedDomain, shouldConfigureNakedDomain } = require('./utils') 3 | const getDomainHostedZoneId = require('./getDomainHostedZoneId') 4 | 5 | module.exports = async (config, params = {}) => { 6 | params.log = params.log || (() => { }) 7 | const { log } = params 8 | const { domain, distributionUrl } = params 9 | const nakedDomain = getNakedDomain(domain) 10 | const domainHostedZoneId = params.domainHostedZoneId || (await getDomainHostedZoneId(config, params)) 11 | 12 | const route53 = new AWS.Route53(config) 13 | 14 | log(`Configuring DNS records for domain "${domain}"`) 15 | 16 | const dnsRecordParams = { 17 | HostedZoneId: domainHostedZoneId, 18 | ChangeBatch: { 19 | Changes: [ 20 | { 21 | Action: 'UPSERT', 22 | ResourceRecordSet: { 23 | Name: domain, 24 | Type: 'A', 25 | AliasTarget: { 26 | HostedZoneId: 'Z2FDTNDATAQYW2', // this is a constant that you can get from here https://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region 27 | DNSName: distributionUrl, 28 | EvaluateTargetHealth: false 29 | } 30 | } 31 | } 32 | ] 33 | } 34 | } 35 | 36 | if (shouldConfigureNakedDomain(domain)) { 37 | log(`Configuring DNS records for domain "${nakedDomain}"`) 38 | dnsRecordParams.ChangeBatch.Changes.push({ 39 | Action: 'UPSERT', 40 | ResourceRecordSet: { 41 | Name: nakedDomain, 42 | Type: 'A', 43 | AliasTarget: { 44 | HostedZoneId: 'Z2FDTNDATAQYW2', // this is a constant that you can get from here https://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region 45 | DNSName: distributionUrl, 46 | EvaluateTargetHealth: false 47 | } 48 | } 49 | }) 50 | } 51 | 52 | await route53.changeResourceRecordSets(dnsRecordParams).promise() 53 | 54 | return { domainHostedZoneId } 55 | } 56 | -------------------------------------------------------------------------------- /src/deployDistributionDomain.js: -------------------------------------------------------------------------------- 1 | const deployCertificate = require('./deployCertificate') 2 | const deployDistributionDns = require('./deployDistributionDns') 3 | const addDomainToDistribution = require('./addDomainToDistribution') 4 | 5 | module.exports = async (config, params = {}) => { 6 | const { certificateArn, certificateStatus, domainHostedZoneId } = await deployCertificate( 7 | config, 8 | params 9 | ) 10 | 11 | params.certificateArn = certificateArn 12 | params.certificateStatus = certificateStatus 13 | params.domainHostedZoneId = domainHostedZoneId 14 | 15 | // cannot add domain to distribution unless it was finally issued 16 | if (certificateStatus === 'ISSUED') { 17 | const { distributionUrl } = await addDomainToDistribution(config, params) 18 | 19 | params.distributionUrl = distributionUrl 20 | 21 | await deployDistributionDns(config, params) 22 | } 23 | 24 | return { 25 | certificateArn, 26 | certificateStatus, 27 | domainHostedZoneId 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/deployLambda.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const fs = require('fs') 3 | const { sleep, zip } = require('./utils') 4 | 5 | 6 | const getVpcConfig = (vpcConfig) => { 7 | if (vpcConfig == 'undefined' || vpcConfig == null) { 8 | return { 9 | SecurityGroupIds: [], 10 | SubnetIds: [] 11 | } 12 | } 13 | 14 | return { 15 | SecurityGroupIds: vpcConfig.securityGroupIds, 16 | SubnetIds: vpcConfig.subnetIds 17 | } 18 | } 19 | 20 | const updateOrCreateLambda = async (config, params = {}) => { 21 | try { 22 | if (!params.lambdaName) { 23 | throw new Error(`Missing lambdaName param.`) 24 | } 25 | 26 | if (!params.roleArn) { 27 | // todo would it be valuable to create the role on behalf of the user? 28 | throw new Error(`Missing roleArn param.`) 29 | } 30 | 31 | // todo support src as string and create zip on the fly without access to fs 32 | if (!params.lambdaSrc) { 33 | throw new Error(`Missing lambdaSrc param.`) 34 | } 35 | 36 | // validate lambdaSrc is path to zip, path to dir, or buffer 37 | if (typeof params.lambdaSrc === 'string') { 38 | if (fs.lstatSync(params.lambdaSrc).isDirectory()) { 39 | // path to directory 40 | params.lambdaSrc = zip(params.lambdaSrc) 41 | } else { 42 | // path to zip file 43 | // todo validate it's a path to zip file 44 | params.lambdaSrc = await fs.promises.readFile(params.lambdaSrc) 45 | } 46 | } 47 | 48 | const lambda = new AWS.Lambda(config) 49 | 50 | const vpcConfig = getVpcConfig(params.vpcConfig) 51 | 52 | try { 53 | const updateFunctionConfigurationParams = { 54 | FunctionName: params.lambdaName, // required 55 | Description: params.description || ``, 56 | Handler: params.handler || 'handler.handler', 57 | Role: params.roleArn, // required 58 | MemorySize: params.memory || 3008, 59 | Timeout: params.timeout || 300, 60 | Layers: params.layers || [], 61 | Runtime: params.runtime || 'nodejs12.x', 62 | Environment: { 63 | Variables: params.env || {} 64 | }, 65 | VpcConfig: vpcConfig 66 | } 67 | 68 | await lambda.updateFunctionConfiguration(updateFunctionConfigurationParams).promise() 69 | 70 | // Updates (like vpc changes) need to complete before calling updateFunctionCode 71 | await lambda.waitFor('functionUpdated', { FunctionName: params.lambdaName }).promise() 72 | 73 | const updateFunctionCodeParams = { 74 | FunctionName: params.lambdaName, // required 75 | ZipFile: params.lambdaSrc, // required 76 | Publish: params.publish === true ? true : false 77 | } 78 | 79 | const lambdaRes = await lambda.updateFunctionCode(updateFunctionCodeParams).promise() 80 | 81 | return { 82 | lambdaArn: lambdaRes.FunctionArn, 83 | lambdaSize: lambdaRes.CodeSize, 84 | lambdaSha: lambdaRes.CodeSha256 85 | } 86 | } catch (e) { 87 | if (e.code !== 'ResourceNotFoundException') { 88 | throw e 89 | } 90 | const createFunctionParams = { 91 | FunctionName: params.lambdaName, // required 92 | Description: params.description || ``, 93 | Handler: params.handler || 'handler.handler', 94 | Code: { 95 | ZipFile: params.lambdaSrc // required 96 | }, 97 | Environment: { 98 | Variables: params.env || {} 99 | }, 100 | Role: params.roleArn, // required 101 | MemorySize: params.memory || 3008, 102 | Timeout: params.timeout || 300, 103 | Layers: params.layers || [], 104 | Runtime: params.runtime || 'nodejs12.x', 105 | Publish: params.publish === true ? true : false, 106 | VpcConfig: vpcConfig 107 | } 108 | 109 | const lambdaRes = await lambda.createFunction(createFunctionParams).promise() 110 | 111 | return { 112 | lambdaArn: lambdaRes.FunctionArn, 113 | lambdaSize: lambdaRes.CodeSize, 114 | lambdaSha: lambdaRes.CodeSha256 115 | } 116 | } 117 | } catch (e) { 118 | if (e.message.includes('The role defined for the function cannot be assumed by Lambda')) { 119 | await sleep(1000) 120 | return updateOrCreateLambda(config, params) 121 | } 122 | throw e 123 | } 124 | } 125 | 126 | module.exports = updateOrCreateLambda 127 | -------------------------------------------------------------------------------- /src/deployRole.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const removeRolePolicies = require('./removeRolePolicies.js') 3 | 4 | const updateRolePolicy = async (config, params = {}) => { 5 | const iam = new AWS.IAM(config) 6 | 7 | // clear previously deployed policy arns if any 8 | await removeRolePolicies(config, params) 9 | 10 | if (typeof params.policy === 'string') { 11 | // policy is an arn of a managed policy 12 | await iam 13 | .attachRolePolicy({ 14 | RoleName: params.roleName, 15 | PolicyArn: params.policy 16 | }) 17 | .promise() 18 | } else { 19 | // Otherwise, create an inline policy 20 | 21 | // Policies can either be a full policy object or an array of Statements. 22 | let policyDocument 23 | if (Array.isArray(params.policy)) { 24 | policyDocument = { 25 | Version: '2012-10-17', 26 | Statement: params.policy 27 | } 28 | } else if (params.policy.Statement) { 29 | policyDocument = params.policy 30 | } else { 31 | throw new Error( 32 | 'Invalid "policy" param. This can either be a standard IAM Policy object, or an array of Statements to be included in a larger policy object.' 33 | ) 34 | } 35 | 36 | await iam 37 | .putRolePolicy({ 38 | RoleName: params.roleName, 39 | PolicyName: params.roleName, 40 | PolicyDocument: JSON.stringify(policyDocument) 41 | }) 42 | .promise() 43 | } 44 | } 45 | 46 | const updateRole = async (config, params = {}) => { 47 | const iam = new AWS.IAM(config) 48 | 49 | const res = await iam.getRole({ RoleName: params.roleName }).promise() 50 | 51 | if (!params.assumeRolePolicyDocument) { 52 | params.assumeRolePolicyDocument = { 53 | Version: '2012-10-17', 54 | Statement: { 55 | Effect: 'Allow', 56 | Principal: { 57 | Service: params.service 58 | }, 59 | Action: 'sts:AssumeRole' 60 | } 61 | } 62 | } 63 | 64 | await iam 65 | .updateAssumeRolePolicy({ 66 | RoleName: params.roleName, 67 | PolicyDocument: JSON.stringify(params.assumeRolePolicyDocument) 68 | }) 69 | .promise() 70 | 71 | await updateRolePolicy(config, params) 72 | 73 | return res.Role.Arn 74 | } 75 | 76 | const createRole = async (config, params = {}) => { 77 | const iam = new AWS.IAM(config) 78 | 79 | if (!params.assumeRolePolicyDocument) { 80 | params.assumeRolePolicyDocument = { 81 | Version: '2012-10-17', 82 | Statement: { 83 | Effect: 'Allow', 84 | Principal: { 85 | Service: params.service 86 | }, 87 | Action: 'sts:AssumeRole' 88 | } 89 | } 90 | } 91 | 92 | const res = await iam 93 | .createRole({ 94 | RoleName: params.roleName, 95 | Description: params.roleDescription || null, 96 | Path: '/', 97 | AssumeRolePolicyDocument: JSON.stringify(params.assumeRolePolicyDocument) 98 | }) 99 | .promise() 100 | 101 | await updateRolePolicy(config, params) 102 | 103 | return res.Role.Arn 104 | } 105 | 106 | module.exports = async (config, params = {}) => { 107 | /** 108 | * Validate 109 | */ 110 | if (!params.roleName) { 111 | throw new Error(`Missing "roleName" param.`) 112 | } 113 | 114 | params.service = params.service || 'lambda.amazonaws.com' 115 | params.policy = params.policy || 'arn:aws:iam::aws:policy/AdministratorAccess' 116 | 117 | // assumeRolePolicyDocument should cancel out "service" 118 | if (params.assumeRolePolicyDocument) { 119 | params.service = null 120 | } 121 | 122 | try { 123 | const roleArn = await updateRole(config, params) 124 | return { roleArn } 125 | } catch (e) { 126 | if (e.code === 'NoSuchEntity') { 127 | const roleArn = await createRole(config, params) 128 | return { roleArn } 129 | } 130 | throw e 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/deployStack.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const { 3 | equals, 4 | mergeDeepRight, 5 | head, 6 | isEmpty, 7 | isNil, 8 | keys, 9 | map, 10 | merge, 11 | not, 12 | pick, 13 | reduce, 14 | toPairs 15 | } = require('ramda') 16 | 17 | const waitForStack = require('./waitForStack') 18 | 19 | /** 20 | * Converts stack outputs to an object 21 | * @param {array} outputs 22 | * @returns {object} stack outputs 23 | */ 24 | const stackOutputsToObject = (outputs) => 25 | reduce((acc, { OutputKey, OutputValue }) => merge(acc, { [OutputKey]: OutputValue }), {}, outputs) 26 | 27 | /** 28 | * Fetches previously deployed stack 29 | * @param {object} cloudformation cloudformation client 30 | * @param {object} config config object 31 | * @returns {object} stack and info if stack needs to be updated 32 | */ 33 | const getPreviousStack = async (config, params) => { 34 | const cloudformation = new AWS.CloudFormation(config) 35 | 36 | let previousTemplate 37 | let stack 38 | 39 | try { 40 | previousTemplate = await cloudformation 41 | .getTemplate({ StackName: params.stackName, TemplateStage: 'Original' }) 42 | .promise() 43 | } catch (error) { 44 | if (error.message !== `Stack with id ${params.stackName} does not exist`) { 45 | throw error 46 | } 47 | } 48 | 49 | if (isNil(previousTemplate)) { 50 | return { 51 | stack: {}, 52 | needsUpdate: true 53 | } 54 | } 55 | 56 | try { 57 | const { Stacks } = await cloudformation 58 | .describeStacks({ StackName: params.stackName }) 59 | .promise() 60 | stack = head(Stacks) 61 | } catch (error) { 62 | if (error.message !== `Stack with id ${params.stackName} does not exist`) { 63 | throw error 64 | } 65 | } 66 | 67 | const previousConfig = { 68 | parameters: reduce( 69 | (acc, { ParameterKey, ParameterValue }) => merge(acc, { [ParameterKey]: ParameterValue }), 70 | {}, 71 | stack.Parameters 72 | ), 73 | role: stack.RoleARN, 74 | capabilities: stack.Capabilities, 75 | rollbackConfiguration: stack.RollbackConfiguration 76 | } 77 | 78 | if ( 79 | equals(previousTemplate.TemplateBody, JSON.stringify(params.template)) && 80 | equals(previousConfig, pick(keys(previousConfig), params)) 81 | ) { 82 | return { 83 | stack, 84 | needsUpdate: false 85 | } 86 | } 87 | 88 | return { 89 | stack, 90 | exists: not(isEmpty(stack)), 91 | needsUpdate: true 92 | } 93 | } 94 | 95 | /** 96 | * Fetches stack outputs 97 | * @param {object} cloudformation 98 | * @param {object} config 99 | * @returns {array} stack outputs 100 | */ 101 | const fetchOutputs = async (config, params) => { 102 | const cloudformation = new AWS.CloudFormation(config) 103 | const { Stacks } = await cloudformation.describeStacks({ StackName: params.stackName }).promise() 104 | return stackOutputsToObject(head(Stacks).Outputs) 105 | } 106 | 107 | /** 108 | * Updates stack termination protection 109 | * @param {object} cloudformation 110 | * @param {object} config 111 | * @param {boolean} terminationProtectionEnabled 112 | */ 113 | const updateTerminationProtection = async (config, params, terminationProtectionEnabled) => { 114 | const cloudformation = new AWS.CloudFormation(config) 115 | if (not(equals(terminationProtectionEnabled, params.enableTerminationProtection))) { 116 | await cloudformation 117 | .updateTerminationProtection({ 118 | EnableTerminationProtection: params.enableTerminationProtection, 119 | StackName: params.stackName 120 | }) 121 | .promise() 122 | } 123 | } 124 | 125 | /** 126 | * Creates or updates the CloudFormation stack 127 | * @param {object} config aws sdk configuration object 128 | * @param {object} params method params 129 | * @returns {array} stack outputs 130 | */ 131 | const createOrUpdateStack = async (config, params) => { 132 | const cloudformation = new AWS.CloudFormation(config) 133 | 134 | const createorUpdateParams = { 135 | StackName: params.stackName, 136 | Capabilities: params.capabilities, 137 | RoleARN: params.role, 138 | RollbackConfiguration: params.rollbackConfiguration, 139 | Parameters: map( 140 | ([key, value]) => ({ 141 | ParameterKey: key, 142 | ParameterValue: value 143 | }), 144 | toPairs(params.parameters) 145 | ), 146 | TemplateBody: JSON.stringify(params.template) 147 | } 148 | 149 | if (not(params.exists)) { 150 | await cloudformation 151 | .createStack(merge(createorUpdateParams, { DisableRollback: params.disableRollback })) 152 | .promise() 153 | } else { 154 | try { 155 | await cloudformation.updateStack(createorUpdateParams).promise() 156 | } catch (error) { 157 | if (error.message !== 'No updates are to be performed.') { 158 | throw error 159 | } 160 | } 161 | } 162 | 163 | const waitForStackParams = { 164 | stackName: params.stackName, 165 | successEvent: /^(CREATE|UPDATE)_COMPLETE$/, 166 | failureEvent: /^(.+_FAILED|.*ROLLBACK_COMPLETE)$/ 167 | } 168 | 169 | const stacks = await waitForStack(config, waitForStackParams) 170 | 171 | return stackOutputsToObject(head(stacks).Outputs) 172 | } 173 | 174 | const defaults = { 175 | enableTerminationProtection: false, 176 | parameters: {}, 177 | role: undefined, 178 | rollbackConfiguration: {}, 179 | disableRollback: false, 180 | capabilities: [], 181 | timestamp: Date.now() 182 | } 183 | 184 | module.exports = async (config, params) => { 185 | params = mergeDeepRight(defaults, params) 186 | if (!params.template || !params.stackName) { 187 | throw new Error('"stackName" and "template" inputs are required.') 188 | } 189 | 190 | let stackOutputs = {} 191 | const previousStack = await getPreviousStack(config, params) 192 | 193 | if (previousStack.needsUpdate) { 194 | stackOutputs = await createOrUpdateStack(config, { ...params, exists: previousStack.exists }) 195 | } else { 196 | stackOutputs = await fetchOutputs(config, params) 197 | } 198 | 199 | await updateTerminationProtection( 200 | config, 201 | params, 202 | !!previousStack.stack.EnableTerminationProtection 203 | ) 204 | 205 | // todo changing stack name 206 | 207 | return stackOutputs 208 | } 209 | -------------------------------------------------------------------------------- /src/disableDistribution.js: -------------------------------------------------------------------------------- 1 | const deployDistribution = require('./deployDistribution') 2 | 3 | module.exports = async (config, params = {}) => { 4 | const { distributionId } = params 5 | if (!distributionId) { 6 | throw new Error(`Missing "distributionId" param`) 7 | } 8 | 9 | const deployDistributionParams = { 10 | distributionId, 11 | Enabled: false, 12 | Aliases: { 13 | Quantity: 0, 14 | Items: [] 15 | } 16 | } 17 | 18 | return deployDistribution(config, deployDistributionParams) 19 | } 20 | -------------------------------------------------------------------------------- /src/getAccountId.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const { memoizeWith, identity } = require('ramda') 3 | 4 | const getAccountId = async (config) => { 5 | const sts = new AWS.STS(config) 6 | 7 | const res = await sts.getCallerIdentity({}).promise() 8 | 9 | return res.Account 10 | } 11 | 12 | module.exports = memoizeWith(identity, getAccountId) 13 | -------------------------------------------------------------------------------- /src/getAppSyncResolversPolicy.js: -------------------------------------------------------------------------------- 1 | const getLambdaArn = require('./getLambdaArn') 2 | const getTableArn = require('./getTableArn') 3 | const getElasticSearchArn = require('./getElasticSearchArn') 4 | 5 | module.exports = async (config, params) => { 6 | const resolvers = params 7 | const policy = [] 8 | 9 | for (const type in resolvers) { 10 | for (const field in resolvers[type]) { 11 | const resolver = resolvers[type][field] 12 | 13 | if (resolver.lambda) { 14 | // lambda 15 | const lambdaArn = `${await getLambdaArn(config, { lambdaName: resolver.lambda })}*` 16 | policy.push({ 17 | Effect: 'Allow', 18 | Action: ['lambda:invokeFunction'], 19 | Resource: lambdaArn 20 | }) 21 | } else if (resolver.table) { 22 | // dynamodb 23 | const tableArn = await getTableArn(config, { tableName: resolver.table }) 24 | policy.push({ 25 | Effect: 'Allow', 26 | Action: [ 27 | 'dynamodb:DeleteItem', 28 | 'dynamodb:GetItem', 29 | 'dynamodb:PutItem', 30 | 'dynamodb:Query', 31 | 'dynamodb:Scan', 32 | 'dynamodb:UpdateItem', 33 | 'dynamodb:BatchGetItem', 34 | 'dynamodb:BatchWriteItem' 35 | ], 36 | Resource: `${tableArn}*` 37 | }) 38 | } else if (resolver.endpoint) { 39 | // elastic search 40 | const elasticSearchArn = await getElasticSearchArn(config, { 41 | endpoint: resolver.endpoint 42 | }) 43 | 44 | policy.push({ 45 | Effect: 'Allow', 46 | Action: [ 47 | 'es:ESHttpDelete', 48 | 'es:ESHttpGet', 49 | 'es:ESHttpHead', 50 | 'es:ESHttpPost', 51 | 'es:ESHttpPut' 52 | ], 53 | Resource: elasticSearchArn 54 | }) 55 | } else if (resolver.relationalDatabaseSourceType) { 56 | // relational database 57 | 58 | policy.push({ 59 | Effect: 'Allow', 60 | Action: [ 61 | 'rds-data:DeleteItems', 62 | 'rds-data:ExecuteSql', 63 | 'rds-data:ExecuteStatement', 64 | 'rds-data:GetItems', 65 | 'rds-data:InsertItems', 66 | 'rds-data:UpdateItems' 67 | ], 68 | Resource: [`${resolver.dbClusterIdentifier}*`, `${resolver.dbClusterIdentifier}:*`] 69 | }) 70 | 71 | policy.push({ 72 | Effect: 'Allow', 73 | Action: ['secretsmanager:GetSecretValue'], 74 | Resource: `${resolver.awsSecretStoreArn}*` 75 | }) 76 | } 77 | } 78 | } 79 | 80 | return policy 81 | } 82 | -------------------------------------------------------------------------------- /src/getCloudWatchLogGroupArn.js: -------------------------------------------------------------------------------- 1 | const getAccountId = require('./getAccountId') 2 | 3 | module.exports = async (config, params) => { 4 | if (!params.region) { 5 | params.region = config.region 6 | } 7 | 8 | if (!params.accountId) { 9 | params.accountId = await getAccountId(config) 10 | } 11 | 12 | if (params.lambdaName) { 13 | params.logGroupName = `/aws/lambda/${params.lambdaName}` 14 | } 15 | 16 | if (params.logGroupName) { 17 | throw new Error(`Missing "logGroupName" param.`) 18 | } 19 | 20 | const arn = `arn:aws:logs:${params.region}:${params.accountId}:log-group:${params.logGroupName}:*` 21 | 22 | return arn 23 | } 24 | -------------------------------------------------------------------------------- /src/getDomainHostedZoneId.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const { getNakedDomain } = require('./utils') 3 | 4 | module.exports = async (config, params = {}) => { 5 | params.log = params.log || (() => { }) 6 | const { log } = params 7 | const nakedDomain = getNakedDomain(params.domain) 8 | const route53 = new AWS.Route53(config) 9 | 10 | log(`Fetching the Hosted Zone ID for domain "${nakedDomain}"`) 11 | const hostedZonesRes = await route53.listHostedZonesByName().promise() 12 | 13 | const hostedZone = hostedZonesRes.HostedZones.find( 14 | // Name has a period at the end, so we're using includes rather than equals 15 | (zone) => zone.Name.includes(nakedDomain) 16 | ) 17 | 18 | const domainHostedZoneId = hostedZone ? hostedZone.Id.replace('/hostedzone/', '') : null 19 | 20 | return { domainHostedZoneId } 21 | } 22 | -------------------------------------------------------------------------------- /src/getElasticSearchArn.js: -------------------------------------------------------------------------------- 1 | const getAccountId = require('./getAccountId') 2 | 3 | module.exports = async (config, params) => { 4 | if (!params.region) { 5 | params.region = config.region 6 | } 7 | 8 | if (!params.accountId) { 9 | params.accountId = await getAccountId(config) 10 | } 11 | 12 | if (params.endpoint) { 13 | const result = /^https:\/\/([a-z0-9\-]+\.\w{2}\-[a-z]+\-\d\.es\.amazonaws\.com)$/.exec( 14 | params.endpoint 15 | ) 16 | params.domain = result[1] 17 | } 18 | 19 | const arn = `arn:aws:es:${params.region}:${params.accountId}:domain/${params.domain}` 20 | 21 | return arn 22 | } 23 | -------------------------------------------------------------------------------- /src/getLambdaArn.js: -------------------------------------------------------------------------------- 1 | const getAccountId = require('./getAccountId') 2 | 3 | module.exports = async (config, params) => { 4 | if (!params.region) { 5 | params.region = config.region 6 | } 7 | 8 | if (!params.accountId) { 9 | params.accountId = await getAccountId(config) 10 | } 11 | 12 | const arn = `arn:aws:lambda:${params.region}:${params.accountId}:function:${params.lambdaName}` 13 | 14 | return arn 15 | } 16 | -------------------------------------------------------------------------------- /src/getMetrics.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const moment = require('moment') 3 | 4 | module.exports = async ( 5 | config, 6 | { 7 | rangeStart, 8 | rangeEnd, 9 | resources, 10 | }) => { 11 | 12 | const cloudwatch = new AWS.CloudWatch(config); 13 | 14 | /** 15 | * Validate parameters 16 | */ 17 | 18 | rangeStart = rangeStart || moment.utc().toISOString() 19 | rangeEnd = rangeEnd || moment.utc().subtract(1, 'days').toISOString() 20 | resources = resources || [] 21 | 22 | // Ensure resources were submitted 23 | if (!resources.length) { 24 | throw new Error(`You provided no resources to fetch queries from`) 25 | } 26 | 27 | // Validate: ISO8601 timestamps 28 | if (!moment(rangeStart, moment.ISO_8601).isValid()) { 29 | throw new Error(`Param "rangeStart" is not a valid IS)8601 timestamp: ${rangeStart}`); 30 | } 31 | if (!moment(rangeEnd, moment.ISO_8601).isValid()) { 32 | throw new Error(`Param "rangeEnd" is not a valid IS)8601 timestamp: ${rangeEnd}`); 33 | } 34 | 35 | // Convert to Moment.js objects 36 | rangeStart = moment.utc(rangeStart); 37 | rangeEnd = moment.utc(rangeEnd); 38 | 39 | // Validate: Start is before End 40 | if (rangeStart.isAfter(rangeEnd)) { 41 | throw new Error('The "rangeStart" provided is after the "rangeEnd"'); 42 | } 43 | 44 | // Validate: End is not longer than 30 days 45 | if (rangeStart.diff(rangeEnd, 'days') > 32) { 46 | throw new Error( 47 | `The range cannot be longer than 32 days. The supplied range is: ${rangeStart.diff( 48 | rangeEnd, 49 | 'days' 50 | )}` 51 | ); 52 | } 53 | 54 | /** 55 | * Prepare Response by determining query time range length, 56 | * the unit of time that's most relevant (minutes, hours, days), 57 | * and creating default values for each bucket of time within the time range, 58 | * since Cloudwatch does not return every timestamp within a given 59 | * time range, only if they have values. 60 | * 61 | * It's important that we use the same unit of time as Cloudwatch 62 | * and bucket values by that unit of time. 63 | * 64 | * Later, we'll add Cloudwatch's returned data into our defaults by comparing 65 | * timestamps returned from Cloudwatch against our default values. The timestamps 66 | * in each bucket must match, or we will not be able to accurately add in Cloduwatch's 67 | * returned data into our defaults. 68 | * 69 | */ 70 | 71 | let period; 72 | let timeBuckets; 73 | const xData = []; 74 | const yData = []; 75 | const diffMinutes = Math.ceil(rangeEnd.diff(rangeStart, 'minutes', true)); // 'true' returns decimals 76 | 77 | // Length: 0 mins - 2 hours 78 | if (diffMinutes <= 120) { 79 | period = 60; 80 | rangeStart = rangeStart.startOf('minute'); 81 | rangeEnd = rangeEnd.endOf('minute'); 82 | timeBuckets = rangeEnd.diff(rangeStart, 'minutes'); 83 | // Create values 84 | for (let i = 0; i <= timeBuckets; i++) { 85 | xData.push(rangeStart.clone().add(i, 'minutes').toISOString()); 86 | yData.push(0); 87 | } 88 | } 89 | // Length: 2 hours - 48 hours 90 | else if (diffMinutes > 120 && diffMinutes <= 2880) { 91 | period = 3600; 92 | rangeStart = rangeStart.startOf('hour'); 93 | rangeEnd = rangeEnd.endOf('hour'); 94 | timeBuckets = rangeEnd.diff(rangeStart, 'hours'); 95 | // Create values 96 | for (let i = 0; i <= timeBuckets; i++) { 97 | xData.push(rangeStart.clone().add(i, 'hours').toISOString()); 98 | yData.push(0); 99 | } 100 | } 101 | // Length: 48 hours to 32 days 102 | else if (diffMinutes > 2880) { 103 | period = 86400; 104 | rangeStart = rangeStart.startOf('day'); 105 | rangeEnd = rangeEnd.endOf('day'); 106 | timeBuckets = rangeEnd.diff(rangeStart, 'days'); 107 | // Create values 108 | for (let i = 0; i <= timeBuckets; i++) { 109 | xData.push(rangeStart.clone().add(i, 'days').toISOString()); 110 | yData.push(0); 111 | } 112 | } 113 | 114 | /** 115 | * Get Cloudwatch Queries for each resource requested. 116 | * You can request up to 500 queries via 1 Cloudwatch.getMetricData call 117 | * This tries to get everything in one call... 118 | */ 119 | 120 | let cloudwatchMetricQueries = [] 121 | const resourcesUsed = {} 122 | resources.forEach((resource) => { 123 | 124 | // Check to ensure a valid resource type is used. 125 | if (Object.keys(resourceHandlers).indexOf(resource.type) < 0) { 126 | throw new Error(`Your metric query requested metrics from this AWS resource "${resource.type}" which is either not accurate or not currently supported by this function.`) 127 | } 128 | 129 | // Add queries for supported resource 130 | if (resourceHandlers[resource.type]) { 131 | cloudwatchMetricQueries = cloudwatchMetricQueries.concat(resourceHandlers[resource.type].queries(period, resource)) 132 | } 133 | 134 | // Track which resources are used, so that we can use their transform functions 135 | // to convert their metrics to the standard Components format. 136 | resourcesUsed[resource.type] = true 137 | }) 138 | 139 | // Check to ensure there aren't more than 500 metrics requests 140 | if (cloudwatchMetricQueries.length > 500) { 141 | throw new Error(`Your AWS Cloudwatch query contains ${cloudwatchMetricQueries.length} queries, but Cloudwatch can only support up to 500 queries.`) 142 | } 143 | 144 | // Prepare CloudWatch queries 145 | const params = { 146 | StartTime: rangeStart.unix(), 147 | EndTime: rangeEnd.unix(), 148 | // NextToken: null, // No need for this since we are restricting value counts. 149 | ScanBy: 'TimestampAscending', 150 | MetricDataQueries: cloudwatchMetricQueries 151 | } 152 | 153 | // Fetch data from Cloudwatch 154 | const data = await cloudwatch.getMetricData(params).promise(); 155 | 156 | /** 157 | * Transform metrics to standard Components format and format response 158 | */ 159 | 160 | const result = { 161 | rangeStart: rangeStart.toISOString(), 162 | rangeEnd: rangeEnd.toISOString(), 163 | metrics: [], 164 | }; 165 | 166 | // Loop through each returned metric, transform results, and add to response 167 | data.MetricDataResults.forEach((cwMetric) => { 168 | 169 | // Create metric 170 | let metric = {}; 171 | metric.type = 'bar-v1'; 172 | metric.stat = null; 173 | metric.statText = null; 174 | metric.statColor = '#000000'; 175 | metric.xData = xData.slice(); 176 | metric.yDataSets = [{}]; 177 | metric.yDataSets[0].yData = yData.slice(); 178 | 179 | // Iterate and transform Cloudwatch metric data 180 | cwMetric.Timestamps.forEach((cwVal, i) => { 181 | 182 | // Add data which Cloudwatch has returned by inspecting timestamps of CW's returned data 183 | // If a timestamp exists that matches one of the defaults, add it in. 184 | metric.xData.forEach((xVal, i2) => { 185 | if (moment.utc(xVal).isSame(cwVal)) { 186 | metric.yDataSets[0].yData[i2] = cwMetric.Values[i]; 187 | } 188 | }); 189 | }); 190 | 191 | // Transform data 192 | Object.keys(resourcesUsed).forEach((resourceType) => { 193 | metric = resourceHandlers[resourceType].transforms(cwMetric, metric) 194 | }) 195 | 196 | // Add to results 197 | result.metrics.push(metric) 198 | }) 199 | 200 | return result; 201 | } 202 | 203 | 204 | 205 | 206 | /** 207 | * Resource Handlers 208 | * 209 | * These are the AWS resources this function supports. 210 | * These handlers return pre-made CloudWatch queries for the resource, 211 | * as well as transform logic to convert the resulting data into the 212 | * Serverless Framework Component output types data. 213 | */ 214 | 215 | const resourceHandlers = {} 216 | 217 | /** 218 | * AWS HTTP API 219 | */ 220 | 221 | resourceHandlers.aws_http_api = {} 222 | 223 | /** 224 | * AWS HTTP API Cloudwatch queries 225 | * @param {*} period 226 | * @param {*} apiId 227 | */ 228 | resourceHandlers.aws_http_api.queries = (period, { apiId, stage }) => { 229 | 230 | if (!period || !apiId) { 231 | throw new Error(`Missing required params`) 232 | } 233 | 234 | return [{ 235 | Id: 'api_requests', 236 | ReturnData: true, 237 | MetricStat: { 238 | Metric: { 239 | MetricName: 'Count', 240 | Namespace: 'AWS/ApiGateway', 241 | Dimensions: [ 242 | { 243 | Name: 'Stage', 244 | Value: stage || '$default', 245 | }, 246 | { 247 | Name: 'ApiId', 248 | Value: apiId, 249 | }, 250 | ], 251 | }, 252 | Period: period, 253 | Stat: 'Sum', 254 | }, 255 | }, 256 | { 257 | Id: 'api_errors_500', 258 | ReturnData: true, 259 | MetricStat: { 260 | Metric: { 261 | MetricName: '5xx', 262 | Namespace: 'AWS/ApiGateway', 263 | Dimensions: [ 264 | { 265 | Name: 'Stage', 266 | Value: stage || '$default', 267 | }, 268 | { 269 | Name: 'ApiId', 270 | Value: apiId, 271 | }, 272 | ], 273 | }, 274 | Period: period, 275 | Stat: 'Sum', 276 | }, 277 | }, 278 | { 279 | Id: 'api_errors_400', 280 | ReturnData: true, 281 | MetricStat: { 282 | Metric: { 283 | MetricName: '4xx', 284 | Namespace: 'AWS/ApiGateway', 285 | Dimensions: [ 286 | { 287 | Name: 'Stage', 288 | Value: stage || '$default', 289 | }, 290 | { 291 | Name: 'ApiId', 292 | Value: apiId, 293 | }, 294 | ], 295 | }, 296 | Period: period, 297 | Stat: 'Sum', 298 | }, 299 | }, 300 | { 301 | Id: 'api_latency', 302 | ReturnData: true, 303 | MetricStat: { 304 | Metric: { 305 | MetricName: 'Latency', 306 | Namespace: 'AWS/ApiGateway', 307 | Dimensions: [ 308 | { 309 | Name: 'Stage', 310 | Value: stage || '$default', 311 | }, 312 | { 313 | Name: 'ApiId', 314 | Value: apiId, 315 | }, 316 | ], 317 | }, 318 | Period: period, 319 | Stat: 'Average', 320 | }, 321 | }, 322 | { 323 | Id: 'api_data_processed', 324 | ReturnData: true, 325 | MetricStat: { 326 | Metric: { 327 | MetricName: 'DataProcessed', 328 | Namespace: 'AWS/ApiGateway', 329 | Dimensions: [ 330 | { 331 | Name: 'Stage', 332 | Value: stage || '$default', 333 | }, 334 | { 335 | Name: 'ApiId', 336 | Value: apiId, 337 | }, 338 | ], 339 | }, 340 | Period: period, 341 | Stat: 'Sum', 342 | }, 343 | }, 344 | { 345 | Id: 'api_integration_latency', 346 | ReturnData: true, 347 | MetricStat: { 348 | Metric: { 349 | MetricName: 'IntegrationLatency', 350 | Namespace: 'AWS/ApiGateway', 351 | Dimensions: [ 352 | { 353 | Name: 'Stage', 354 | Value: stage || '$default', 355 | }, 356 | { 357 | Name: 'ApiId', 358 | Value: apiId, 359 | }, 360 | ], 361 | }, 362 | Period: period, 363 | Stat: 'Average', 364 | }, 365 | }] 366 | } 367 | 368 | /** 369 | * AWS HTTP API Cloudwatch transforms 370 | * @param {*} period 371 | * @param {*} apiId 372 | */ 373 | resourceHandlers.aws_http_api.transforms = (cwMetric, metric) => { 374 | 375 | if (!cwMetric || !metric) { 376 | throw new Error(`Missing required params`) 377 | } 378 | 379 | // Total Requests 380 | if (cwMetric.Id === 'api_requests') { 381 | metric.title = 'API Requests'; 382 | metric.description = 383 | 'The total number API requests in a given period to your AWS HTTP API.'; 384 | metric.yDataSets[0].color = '#000000'; 385 | // Get sum 386 | metric.stat = metric.yDataSets[0].yData.reduce((previous, current) => current + previous); 387 | } 388 | 389 | // Errors - 5xx 390 | if (cwMetric.Id === 'api_errors_500') { 391 | metric.title = 'API Errors - 5xx'; 392 | metric.description = 393 | 'The number of serverless-side internal errors captured in a given period from your AWS HTTP API most likely generated as a result of issues within your code.'; 394 | metric.statColor = '#FE5850'; 395 | metric.yDataSets[0].color = '#FE5850'; 396 | // Get sum 397 | metric.stat = metric.yDataSets[0].yData.reduce((previous, current) => current + previous); 398 | } 399 | 400 | // Errors - 4xx 401 | if (cwMetric.Id === 'api_errors_400') { 402 | metric.title = 'API Errors - 4xx'; 403 | metric.description = 404 | 'The number of serverless-side client-generated errors captured in a given period from your AWS HTTP API.'; 405 | metric.statColor = '#FE5850'; 406 | metric.yDataSets[0].color = '#FE5850'; 407 | // Get sum 408 | metric.stat = metric.yDataSets[0].yData.reduce((previous, current) => current + previous); 409 | } 410 | 411 | // Latency 412 | if (cwMetric.Id === 'api_latency') { 413 | metric.title = 'API Latency'; 414 | metric.description = 415 | 'The time between when AWS HTTP API receives a request from a client and when it returns a response to the client. The latency includes the integration latency and other AWS HTTP API overhead.'; 416 | metric.statColor = '#029CE3'; 417 | metric.yDataSets[0].color = '#029CE3'; 418 | // Round decimals 419 | metric.yDataSets[0].yData = metric.yDataSets[0].yData.map((val) => Math.ceil(val)); 420 | // Get sum 421 | metric.stat = metric.yDataSets[0].yData.reduce((previous, current) => current + previous); 422 | // Get average 423 | const filtered = metric.yDataSets[0].yData.filter((x) => x > 0); 424 | metric.stat = Math.ceil(metric.stat / filtered.length); 425 | metric.statText = 'ms'; 426 | } 427 | 428 | // Integration Latency 429 | if (cwMetric.Id === 'api_integration_latency') { 430 | metric.title = 'API Integration Latency'; 431 | metric.description = 432 | 'The time between when AWS HTTP API relays a request to the backend and when it receives a response from the backend.'; 433 | metric.statColor = '#029CE3'; 434 | metric.yDataSets[0].color = '#029CE3'; 435 | // Round decimals 436 | metric.yDataSets[0].yData = metric.yDataSets[0].yData.map((val) => Math.ceil(val)); 437 | // Get sum 438 | metric.stat = metric.yDataSets[0].yData.reduce((previous, current) => current + previous); 439 | // Get average 440 | const filtered = metric.yDataSets[0].yData.filter((x) => x > 0); 441 | metric.stat = Math.ceil(metric.stat / filtered.length); 442 | metric.statText = 'ms'; 443 | } 444 | 445 | // Data Processed in Kilobytes 446 | if (cwMetric.Id === 'api_data_processed') { 447 | metric.title = 'API Data Processed'; 448 | metric.description = 'The amount of data processed in kilobytes.'; 449 | metric.statColor = '#000000'; 450 | metric.yDataSets[0].color = '#000000'; 451 | // Convert to kilobytes 452 | metric.yDataSets[0].yData = metric.yDataSets[0].yData.map((val) => { 453 | const kb = val / Math.pow(1024, 1); 454 | return Math.round(kb * 100) / 100; 455 | }); 456 | // Get sum of bytes 457 | metric.statText = 'kb'; 458 | metric.stat = metric.yDataSets[0].yData.reduce((previous, current) => current + previous); 459 | } 460 | 461 | return metric 462 | } 463 | 464 | /** 465 | * AWS Lambda 466 | */ 467 | 468 | resourceHandlers.aws_lambda = {} 469 | 470 | /** 471 | * AWS Lambda Cloudwatch queries 472 | * @param {*} period 473 | * @param {*} resource.functionName 474 | */ 475 | resourceHandlers.aws_lambda.queries = (period, { functionName }) => { 476 | 477 | if (!period || !functionName) { 478 | throw new Error(`Missing required params`) 479 | } 480 | 481 | return [{ 482 | Id: 'function_invocations', 483 | ReturnData: true, 484 | MetricStat: { 485 | Metric: { 486 | MetricName: 'Invocations', 487 | Namespace: 'AWS/Lambda', 488 | Dimensions: [ 489 | { 490 | Name: 'FunctionName', 491 | Value: functionName, 492 | }, 493 | ], 494 | }, 495 | Period: period, 496 | Stat: 'Sum', 497 | }, 498 | }, 499 | { 500 | Id: 'function_errors', 501 | ReturnData: true, 502 | MetricStat: { 503 | Metric: { 504 | MetricName: 'Errors', 505 | Namespace: 'AWS/Lambda', 506 | Dimensions: [ 507 | { 508 | Name: 'FunctionName', 509 | Value: functionName, 510 | }, 511 | ], 512 | }, 513 | Period: period, 514 | Stat: 'Sum', 515 | }, 516 | }, 517 | { 518 | Id: 'function_throttles', 519 | ReturnData: true, 520 | MetricStat: { 521 | Metric: { 522 | MetricName: 'Throttles', 523 | Namespace: 'AWS/Lambda', 524 | Dimensions: [ 525 | { 526 | Name: 'FunctionName', 527 | Value: functionName, 528 | }, 529 | ], 530 | }, 531 | Period: period, 532 | Stat: 'Sum', 533 | }, 534 | }, 535 | { 536 | Id: 'function_duration', 537 | ReturnData: true, 538 | MetricStat: { 539 | Metric: { 540 | MetricName: 'Duration', 541 | Namespace: 'AWS/Lambda', 542 | Dimensions: [ 543 | { 544 | Name: 'FunctionName', 545 | Value: functionName, 546 | }, 547 | ], 548 | }, 549 | Period: period, 550 | Stat: 'Average', 551 | }, 552 | }] 553 | } 554 | 555 | /** 556 | * AWS Lambda Cloudwatch transforms 557 | * @param {*} period 558 | * @param {*} resource.functionName 559 | */ 560 | resourceHandlers.aws_lambda.transforms = (cwMetric, metric) => { 561 | 562 | if (!cwMetric || !metric) { 563 | throw new Error(`Missing required params`) 564 | } 565 | 566 | // Function Invocations 567 | if (cwMetric.Id === 'function_invocations') { 568 | metric.title = 'Function Invocations'; 569 | metric.description = 570 | 'The number of times your function code is executed, including successful executions and executions that result in a function error. Invocations are not recorded if the invocation request is throttled or otherwise resulted in an invocation error. This equals the number of requests billed.'; 571 | metric.yDataSets[0].color = '#000000'; 572 | // Get sum 573 | metric.stat = metric.yDataSets[0].yData.reduce((previous, current) => current + previous); 574 | } 575 | // Function Errors 576 | if (cwMetric.Id === 'function_errors') { 577 | metric.title = 'Function Errors'; 578 | metric.description = 579 | 'The number of invocations that result in a function error. Function errors include exceptions thrown by your code and exceptions thrown by the Lambda runtime. The runtime returns errors for issues such as timeouts and configuration errors. To calculate the error rate, divide the value of Errors by the value of Invocations.'; 580 | metric.yDataSets[0].color = '#FE5850'; 581 | // Get sum 582 | metric.stat = metric.yDataSets[0].yData.reduce((previous, current) => current + previous); 583 | } 584 | 585 | // Function Throttles 586 | if (cwMetric.Id === 'function_throttles') { 587 | metric.title = 'Function Throttles'; 588 | metric.description = 589 | 'The number of invocation requests that are throttled. When all function instances are processing requests and no concurrency is available to scale up, Lambda rejects additional requests with TooManyRequestsException. Throttled requests and other invocation errors do not count as Invocations or Errors.'; 590 | metric.yDataSets[0].color = '#FE5850'; 591 | // Get sum 592 | metric.stat = metric.yDataSets[0].yData.reduce((previous, current) => current + previous); 593 | } 594 | 595 | // Function Latency 596 | if (cwMetric.Id === 'function_duration') { 597 | metric.title = 'Function Latency'; 598 | metric.description = 599 | 'The amount of time that your function code spends processing an event. For the first event processed by an instance of your function, this includes initialization time. The billed duration for an invocation is the value of Duration rounded up to the nearest 100 milliseconds.'; 600 | metric.yDataSets[0].color = '#029CE3'; 601 | metric.statColor = '#029CE3'; 602 | metric.statText = 'ms'; 603 | // Round decimals 604 | metric.yDataSets[0].yData = metric.yDataSets[0].yData.map((val) => Math.ceil(val)); 605 | // Get sum 606 | metric.stat = metric.yDataSets[0].yData.reduce((previous, current) => current + previous); 607 | // Get average 608 | const filtered = metric.yDataSets[0].yData.filter((x) => x > 0); 609 | metric.stat = Math.ceil(metric.stat / filtered.length); 610 | } 611 | 612 | return metric; 613 | } 614 | 615 | /** 616 | * AWS Cloudfront 617 | */ 618 | 619 | resourceHandlers.aws_cloudfront = {} 620 | 621 | /** 622 | * AWS Cloudfront Cloudwatch queries 623 | * @param {*} period 624 | * @param {*} distributionId 625 | */ 626 | resourceHandlers.aws_cloudfront.queries = (period, { distributionId, stage }) => { 627 | 628 | if (!period || !distributionId) { 629 | throw new Error(`Missing required params`) 630 | } 631 | 632 | return [ 633 | { 634 | Id: 'distribution_requests', 635 | ReturnData: true, 636 | MetricStat: { 637 | Metric: { 638 | MetricName: 'Requests', 639 | Namespace: 'AWS/CloudFront', 640 | Dimensions: [ 641 | { 642 | Name: 'Region', 643 | Value: 'Global', // Cloudfront is global by default, so this property is always "Global", adding it in just to be sure. 644 | }, 645 | { 646 | Name: 'DistributionId', 647 | Value: distributionId, 648 | }, 649 | ], 650 | }, 651 | Period: period, 652 | Stat: 'Sum', 653 | }, 654 | }, 655 | { 656 | Id: 'distribution_error_rate', 657 | ReturnData: true, 658 | MetricStat: { 659 | Metric: { 660 | MetricName: 'TotalErrorRate', 661 | Namespace: 'AWS/CloudFront', 662 | Dimensions: [ 663 | { 664 | Name: 'Region', 665 | Value: 'Global', // Cloudfront is global by default, so this property is always "Global", adding it in just to be sure. 666 | }, 667 | { 668 | Name: 'DistributionId', 669 | Value: distributionId, 670 | }, 671 | ], 672 | }, 673 | Period: period, 674 | Stat: 'Average', 675 | Unit: 'Percent', 676 | }, 677 | }, 678 | { 679 | Id: 'distribution_bytes_downloaded', 680 | ReturnData: true, 681 | MetricStat: { 682 | Metric: { 683 | MetricName: 'BytesDownloaded', 684 | Namespace: 'AWS/CloudFront', 685 | Dimensions: [ 686 | { 687 | Name: 'Region', 688 | Value: 'Global', // Cloudfront is global by default, so this property is always "Global", adding it in just to be sure. 689 | }, 690 | { 691 | Name: 'DistributionId', 692 | Value: distributionId, 693 | }, 694 | ], 695 | }, 696 | Period: period, 697 | Stat: 'Sum', 698 | }, 699 | } 700 | // There is more data available if "additional metrics" is enabled... Something to consider for later... 701 | ] 702 | } 703 | 704 | /** 705 | * AWS Cloudfront Cloudwatch transforms 706 | * @param {*} period 707 | * @param {*} apiId 708 | */ 709 | resourceHandlers.aws_cloudfront.transforms = (cwMetric, metric) => { 710 | 711 | if (!cwMetric || !metric) { 712 | throw new Error(`Missing required params`) 713 | } 714 | 715 | // Total Requests 716 | if (cwMetric.Id === 'distribution_requests') { 717 | metric.title = 'Requests'; 718 | metric.description = 719 | 'The total number of viewer requests received by CloudFront, for all HTTP methods and for both HTTP and HTTPS requests. This is not a count of unique users, but of the total requests of assets from Cloudfront. A single webpage may have several assets on it (e.g. images, CSS, JS, etc.).'; 720 | metric.yDataSets[0].color = '#000000'; 721 | // Get sum 722 | metric.stat = metric.yDataSets[0].yData.reduce((previous, current) => current + previous); 723 | } 724 | 725 | // Total Error Rate 726 | if (cwMetric.Id === 'distribution_error_rate') { 727 | metric.title = 'Request Error Rate'; 728 | metric.description = 729 | 'The percentage of all viewer requests for which the response’s HTTP status code is 4xx or 5xx.'; 730 | metric.statColor = '#FE5850'; 731 | metric.yDataSets[0].color = '#FE5850'; 732 | // Get sum 733 | metric.stat = metric.yDataSets[0].yData.reduce((previous, current) => current + previous); 734 | metric.statText = '%'; 735 | } 736 | 737 | // Bytes Downloaded in Kilobytes 738 | if (cwMetric.Id === 'distribution_bytes_downloaded') { 739 | metric.title = 'Data Downloaded'; 740 | metric.description = 'The total number of kilobytes downloaded by viewers for GET, HEAD, and OPTIONS requests.'; 741 | metric.statColor = '#000000'; 742 | metric.yDataSets[0].color = '#000000'; 743 | // Convert to kilobytes 744 | metric.yDataSets[0].yData = metric.yDataSets[0].yData.map((val) => { 745 | const kb = val / Math.pow(1024, 1); 746 | return Math.round(kb * 100) / 100; 747 | }); 748 | // Get sum of bytes 749 | metric.statText = 'kb'; 750 | metric.stat = metric.yDataSets[0].yData.reduce((previous, current) => current + previous); 751 | } 752 | 753 | return metric 754 | } 755 | -------------------------------------------------------------------------------- /src/getRdsArn.js: -------------------------------------------------------------------------------- 1 | const getAccountId = require('./getAccountId') 2 | 3 | module.exports = async (config, params) => { 4 | if (!params.region) { 5 | params.region = config.region 6 | } 7 | 8 | if (!params.accountId) { 9 | params.accountId = await getAccountId(config) 10 | } 11 | 12 | const arn = `arn:aws:rds:${params.region}:${params.accountId}:cluster:${params.dbClusterIdentifier}` 13 | 14 | return arn 15 | } 16 | -------------------------------------------------------------------------------- /src/getRole.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | 3 | module.exports = async (config, { roleName = null }) => { 4 | if (!roleName) { 5 | throw new Error(`Missing "roleName" param.`) 6 | } 7 | 8 | const iam = new AWS.IAM(config) 9 | 10 | /** 11 | * First, fetch the IAM Role because we need to get the PolicyARN to do more 12 | */ 13 | 14 | const getRoleWrapper = () => { 15 | return iam.getRole({ RoleName: roleName }).promise() 16 | .then((result) => { 17 | return result.Role; 18 | }) 19 | .catch((error) => { 20 | if (error.code === 'NoSuchEntity' || error.message.includes('cannot be found')) { 21 | return null; 22 | } 23 | throw error; 24 | }) 25 | } 26 | 27 | 28 | const listRolePoliciesWrapper = () => { 29 | return iam.listRolePolicies({ RoleName: roleName }).promise() 30 | .then((result) => { 31 | return result.PolicyNames || []; 32 | }) 33 | .catch((error) => { 34 | if (error.code === 'NoSuchEntity' || error.message.includes('cannot be found')) { 35 | return []; 36 | } 37 | throw error; 38 | }) 39 | } 40 | 41 | const listAttachedRolePoliciesWrapper = () => { 42 | return iam.listAttachedRolePolicies({ RoleName: roleName }).promise() 43 | .then((result) => { 44 | return result.AttachedPolicies || []; 45 | }) 46 | .catch((error) => { 47 | if (error.code === 'NoSuchEntity' || error.message.includes('cannot be found')) { 48 | return []; 49 | } 50 | throw error; 51 | }) 52 | } 53 | 54 | const results = await Promise.all([ 55 | getRoleWrapper(), 56 | listRolePoliciesWrapper(), 57 | listAttachedRolePoliciesWrapper(), 58 | ]); 59 | 60 | const result = {} 61 | result.Role = results[0]; 62 | result.RoleInlinePolicies = results[1]; 63 | result.RoleAttachedPolicies = results[2]; 64 | 65 | /** 66 | * Fetch each inline role policy. 67 | * The policy documents are included in the response (thankfully!). 68 | */ 69 | 70 | const inlinePolicyPromises = [] 71 | result.RoleInlinePolicies.forEach((policyName) => { 72 | 73 | const wrapper = () => { 74 | return iam.getRolePolicy({ 75 | PolicyName: policyName, 76 | RoleName: roleName, 77 | }).promise() 78 | .then((result) => { 79 | return result 80 | }) 81 | .catch((error) => { 82 | if (error.code === 'NoSuchEntity' || error.message.includes('cannot be found')) { 83 | return null; 84 | } 85 | throw error; 86 | }) 87 | } 88 | 89 | inlinePolicyPromises.push(wrapper()) 90 | }); 91 | 92 | /** 93 | * Fetch each managed policy attached to the role, 94 | * also fetch their policy statements which is 95 | * (yet another) API call. 96 | * 97 | * The policy statements are versioned. Whatever 98 | * is set as the Default Version Id is the in use 99 | * version. 100 | */ 101 | 102 | const managedPolicyPromises = [] 103 | result.RoleAttachedPolicies.forEach((policy) => { 104 | 105 | const wrapper = () => { 106 | return iam.getPolicy({ 107 | PolicyArn: policy.PolicyArn 108 | }).promise() 109 | .then(async (result) => { 110 | result = result.Policy 111 | // Try to fetch the policy statements, which are in a Policy Version 112 | // the "DefaultVersionId" is the current active Policy Version 113 | try { 114 | result.PolicyVersion = await iam.getPolicyVersion({ 115 | PolicyArn: policy.PolicyArn, 116 | VersionId: result.DefaultVersionId 117 | }).promise() 118 | } catch (error) { 119 | if (error.code === 'NoSuchEntity' || error.message.includes('cannot be found')) { } 120 | throw error; 121 | } 122 | return result 123 | }) 124 | .catch((error) => { 125 | if (error.code === 'NoSuchEntity' || error.message.includes('cannot be found')) { 126 | return null; 127 | } 128 | throw error; 129 | }) 130 | } 131 | 132 | managedPolicyPromises.push(wrapper()) 133 | }); 134 | 135 | // Update result with actual policies 136 | result.RoleInlinePolicies = await Promise.all(inlinePolicyPromises); 137 | result.RoleAttachedPolicies = await Promise.all(managedPolicyPromises); 138 | 139 | // Ensure there are no null results 140 | result.RoleInlinePolicies = result.RoleInlinePolicies.filter((policy) => { return policy ? true : false }) 141 | result.RoleAttachedPolicies = result.RoleAttachedPolicies.filter((policy) => { return policy ? true : false }) 142 | 143 | // Return 144 | return result; 145 | } 146 | -------------------------------------------------------------------------------- /src/getRoleArn.js: -------------------------------------------------------------------------------- 1 | const getAccountId = require('./getAccountId') 2 | 3 | module.exports = async (config, params) => { 4 | if (!params.accountId) { 5 | params.accountId = await getAccountId(config) 6 | } 7 | 8 | const arn = `arn:aws:iam::${params.accountId}:role/${params.roleName}` 9 | 10 | return arn 11 | } 12 | -------------------------------------------------------------------------------- /src/getTableArn.js: -------------------------------------------------------------------------------- 1 | const getAccountId = require('./getAccountId') 2 | 3 | module.exports = async (config, params) => { 4 | if (!params.region) { 5 | params.region = config.region 6 | } 7 | 8 | if (!params.accountId) { 9 | params.accountId = await getAccountId(config) 10 | } 11 | 12 | const arn = `arn:aws:dynamodb:${params.region}:${params.accountId}:table/${params.tableName}` 13 | 14 | return arn 15 | } 16 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const deployDistributionDomain = require('./deployDistributionDomain') 3 | const deployCertificate = require('./deployCertificate') 4 | const deployDistributionDns = require('./deployDistributionDns') 5 | const getDomainHostedZoneId = require('./getDomainHostedZoneId') 6 | const addDomainToDistribution = require('./addDomainToDistribution') 7 | const deployRole = require('./deployRole') 8 | const getRole = require('./getRole') 9 | const removeRole = require('./removeRole') 10 | const removeRolePolicies = require('./removeRolePolicies') 11 | const deployLambda = require('./deployLambda') 12 | const deployApigDomainDns = require('./deployApigDomainDns') 13 | const deployAppSyncApi = require('./deployAppSyncApi') 14 | const deployAppSyncSchema = require('./deployAppSyncSchema') 15 | const deployAppSyncResolvers = require('./deployAppSyncResolvers') 16 | const getAppSyncResolversPolicy = require('./getAppSyncResolversPolicy') 17 | const getAccountId = require('./getAccountId') 18 | const getLambdaArn = require('./getLambdaArn') 19 | const getRoleArn = require('./getRoleArn') 20 | const getTableArn = require('./getTableArn') 21 | const getElasticSearchArn = require('./getElasticSearchArn') 22 | const getRdsArn = require('./getRdsArn') 23 | const getCloudWatchLogGroupArn = require('./getCloudWatchLogGroupArn') 24 | const removeLambda = require('./removeLambda') 25 | const removeAppSyncApi = require('./removeAppSyncApi') 26 | const createAppSyncApiKey = require('./createAppSyncApiKey') 27 | const deployAppSyncApiKey = require('./deployAppSyncApiKey') 28 | const deployAppSyncDistribution = require('./deployAppSyncDistribution') 29 | const deployDistribution = require('./deployDistribution') 30 | const disableDistribution = require('./disableDistribution') 31 | const removeDistribution = require('./removeDistribution') 32 | const getMetrics = require('./getMetrics') 33 | const deployStack = require('./deployStack') 34 | const removeStack = require('./removeStack') 35 | const listAllAwsRegions = require('./listAllAwsRegions') 36 | const listAllCloudFormationStacksInARegion = require('./listAllCloudFormationStacksInARegion') 37 | const listAllCloudFormationStacksInAllRegions = require('./listAllCloudFormationStacksInAllRegions') 38 | const listAllCloudFormationStackResources = require('./listAllCloudFormationStackResources') 39 | 40 | /** 41 | * Define AWS Extras class 42 | * @param {*} params 43 | */ 44 | 45 | class Extras { 46 | constructor(config = {}) { 47 | this.config = config 48 | } 49 | 50 | /** 51 | * A convenience function to fetch useful metrics from AWS Cloudwatch for one or many AWS 52 | * resources (e.g. AWS HTTP API, AWS Lambda), and transform their resulting data into 53 | * the standard format that is supported by Serverless Framework Component metrics. 54 | * 55 | * @param {string} params.region AWS region 56 | * @param {string} params.rangeStart ISO8601 timestamp for the start of the date range to query within 57 | * @param {string} params.rangeEnd ISO8601 timestamp for the end of the date range to query within 58 | * @param {array} params.resources An array containing objects representing AWS resources to query against. View examples for supported resources. 59 | * 60 | * @example 61 | * 62 | * // AWS HTTP API 63 | * { 64 | * type: 'aws_http_api', 65 | * apiId: 'j0jafsasf', 66 | * } 67 | * 68 | * // AWS Lambda 69 | * { 70 | * type: 'aws_lambda', 71 | * functionName: 'myLambdaFunction', 72 | * } 73 | * 74 | * // AWS Cloudfront 75 | * { 76 | * type: 'aws_cloudfront', 77 | * distributionId: 'ja9fa9j1', 78 | * } 79 | * 80 | */ 81 | getMetrics(params) { 82 | return getMetrics(this.config, params) 83 | } 84 | 85 | /** 86 | * Deploys a CloudFront distribution domain by adding the domain 87 | */ 88 | deployDistributionDomain(params) { 89 | return deployDistributionDomain(this.config, params) 90 | } 91 | 92 | /** 93 | * Deploys a free ACM certificate for the given domain 94 | */ 95 | deployCertificate(params) { 96 | return deployCertificate(this.config, params) 97 | } 98 | 99 | /** 100 | * Deploys a DNS records for a distribution domain 101 | */ 102 | deployDistributionDns(params) { 103 | return deployDistributionDns(this.config, params) 104 | } 105 | 106 | /** 107 | * Fetches the hosted zone id for the given domain 108 | */ 109 | getDomainHostedZoneId(params) { 110 | return getDomainHostedZoneId(this.config, params) 111 | } 112 | 113 | /** 114 | * Adds a domain or subdomain to a CloudFront Distribution 115 | */ 116 | addDomainToDistribution(params) { 117 | return addDomainToDistribution(this.config, params) 118 | } 119 | 120 | /** 121 | * Updates or creates an IAM Role and Policy 122 | * @param {*} params.roleName The name of the IAM Role 123 | * @param {*} params.roleDescription A description for the role 124 | * @param {*} params.roleTags Tags for the role 125 | * @param {*} params.policy The policy to inline or attach to the role. If you provide a string of an AWS IAM Managed Policy ARN, it will attach it. If you provide an array or object it will inline the policy into the Role. 126 | * @param {*} params.service The "Service" section of the assumeRolePolicyDocument 127 | * @param {*} params.assumeRolePolicyDocument The assumeRolePolicyDocument. Overrides params.service 128 | */ 129 | deployRole(params) { 130 | return deployRole(this.config, params) 131 | } 132 | 133 | /** 134 | * Get an AWS IAM Role, its tags, inline policies and managed policies 135 | * @param {*} params.roleName The name of the IAM Role you want to remove 136 | */ 137 | getRole(params) { 138 | return getRole(this.config, params) 139 | } 140 | 141 | /** 142 | * Deletes the given role and all its attached managed and inline policies 143 | */ 144 | removeRole(params) { 145 | return removeRole(this.config, params) 146 | } 147 | 148 | /** 149 | * Deletes all attached managed and inline policies for the given role 150 | */ 151 | removeRolePolicies(params) { 152 | return removeRolePolicies(this.config, params) 153 | } 154 | 155 | /** 156 | * Updates a lambda if it exists, otherwise creates a new one. 157 | */ 158 | deployLambda(params) { 159 | return deployLambda(this.config, params) 160 | } 161 | 162 | /** 163 | * Deploys the DNS records for an Api Gateway V2 HTTP custom domain 164 | */ 165 | deployApigDomainDns(params) { 166 | return deployApigDomainDns(this.config, params) 167 | } 168 | 169 | /** 170 | * Updates or creates an AppSync API 171 | */ 172 | deployAppSyncApi(params) { 173 | return deployAppSyncApi(this.config, params) 174 | } 175 | 176 | /** 177 | * Updates or creates an AppSync Schema 178 | */ 179 | deployAppSyncSchema(params) { 180 | return deployAppSyncSchema(this.config, params) 181 | } 182 | 183 | /** 184 | * Updates or creates AppSync Resolvers 185 | */ 186 | deployAppSyncResolvers(params) { 187 | return deployAppSyncResolvers(this.config, params) 188 | } 189 | 190 | /** 191 | * Generates the minimum IAM role policy that is required for the given resolvers. 192 | */ 193 | getAppSyncResolversPolicy(params) { 194 | return getAppSyncResolversPolicy(this.config, params) 195 | } 196 | 197 | /** 198 | * Returns the account id of the configured credentials 199 | */ 200 | getAccountId(params) { 201 | return getAccountId(this.config, params) 202 | } 203 | 204 | /** 205 | * Constructs a Lambda ARN from the given Lambda name 206 | */ 207 | getLambdaArn(params) { 208 | return getLambdaArn(this.config, params) 209 | } 210 | 211 | /** 212 | * Constructs an IAM Role ARN from the given Role name 213 | */ 214 | getRoleArn(params) { 215 | return getRoleArn(this.config, params) 216 | } 217 | 218 | /** 219 | * Constructs Table ARN from the given Table name 220 | */ 221 | getTableArn(params) { 222 | return getTableArn(this.config, params) 223 | } 224 | 225 | /** 226 | * Constructs ElasticSearch ARN from the given ElasticSearch domain 227 | */ 228 | getElasticSearchArn(params) { 229 | return getElasticSearchArn(this.config, params) 230 | } 231 | 232 | /** 233 | * Constructs RDS ARN from the given dbClusterIdentifier 234 | */ 235 | getRdsArn(params) { 236 | return getRdsArn(this.config, params) 237 | } 238 | 239 | /** 240 | * Constructs CloudWatch Log Group ARN from the given lambdaName or logGroupName 241 | */ 242 | getCloudWatchLogGroupArn(params) { 243 | return getCloudWatchLogGroupArn(this.config, params) 244 | } 245 | 246 | /** 247 | * Removes a Lambda function. Does nothing if already removed. 248 | */ 249 | removeLambda(params) { 250 | return removeLambda(this.config, params) 251 | } 252 | 253 | /** 254 | * Removes an AppSync API. Does nothing if already removed. 255 | */ 256 | removeAppSyncApi(params) { 257 | return removeAppSyncApi(this.config, params) 258 | } 259 | 260 | /** 261 | * Creates an AppSync API Key that is valid for 1 year 262 | */ 263 | createAppSyncApiKey(params) { 264 | return createAppSyncApiKey(this.config, params) 265 | } 266 | 267 | /** 268 | * Updates or creats an AppSync API Key 269 | */ 270 | deployAppSyncApiKey(params) { 271 | return deployAppSyncApiKey(this.config, params) 272 | } 273 | 274 | /** 275 | * Updates or creats a CloudFront distribution for AppSync API 276 | */ 277 | deployAppSyncDistribution(params) { 278 | return deployAppSyncDistribution(this.config, params) 279 | } 280 | 281 | /** 282 | * Updates or creats a CloudFront distribution for AppSync API 283 | */ 284 | deployDistribution(params) { 285 | return deployDistribution(this.config, params) 286 | } 287 | 288 | /** 289 | * Disables a CloudFront distribution 290 | */ 291 | disableDistribution(params) { 292 | return disableDistribution(this.config, params) 293 | } 294 | 295 | /** 296 | * Removes a CloudFront distribution. If distribution is enabled, it just disables it. 297 | * If distribution is already disabled, it removes it completely. 298 | */ 299 | removeDistribution(params) { 300 | return removeDistribution(this.config, params) 301 | } 302 | 303 | /** 304 | * Creates or updates a CloudFormation stack. 305 | */ 306 | deployStack(params) { 307 | return deployStack(this.config, params) 308 | } 309 | 310 | /** 311 | * Removes a CloudFormation stack 312 | */ 313 | removeStack(params) { 314 | return removeStack(this.config, params) 315 | } 316 | 317 | /** 318 | * List all AWS Regions currently available, formatted for SDK input, in an array 319 | */ 320 | listAllAwsRegions() { 321 | return listAllAwsRegions() 322 | } 323 | 324 | /** 325 | * Performs one or multiple API requests until all AWS CloudFormation Stacks are collected within a specified region and returned in an array. 326 | */ 327 | listAllCloudFormationStacksInARegion(params) { 328 | return listAllCloudFormationStacksInARegion(this.config, params) 329 | } 330 | 331 | /** 332 | * Performs one or multiple API requests until all AWS CloudFormation Stacks in all regions are collected and returned in an array. 333 | */ 334 | listAllCloudFormationStacksInAllRegions(params) { 335 | return listAllCloudFormationStacksInAllRegions(this.config, params) 336 | } 337 | 338 | /** 339 | * Performs one or multiple API requests until summaries for all resources in an AWS CloudFormation Stack are collected and returned as an array. 340 | */ 341 | listAllCloudFormationStackResources(params) { 342 | return listAllCloudFormationStackResources(this.config, params) 343 | } 344 | } 345 | 346 | /** 347 | * Export 348 | */ 349 | 350 | AWS.Extras = Extras 351 | module.exports = AWS 352 | -------------------------------------------------------------------------------- /src/listAllAwsRegions.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | return [ 3 | 'us-east-1', 4 | 'us-east-2', 5 | 'us-west-1', 6 | 'us-west-2', 7 | 'af-south-1', 8 | 'ap-east-1', 9 | 'ap-south-1', 10 | 'ap-northeast-1', 11 | 'ap-northeast-2', 12 | 'ap-southeast-1', 13 | 'ap-southeast-2', 14 | 'ca-central-1', 15 | 'cn-north-1', 16 | 'cn-northwest-1', 17 | 'eu-central-1', 18 | 'eu-west-1', 19 | 'eu-west-2', 20 | 'eu-west-3', 21 | 'eu-south-1', 22 | 'eu-north-1', 23 | 'me-south-1', 24 | 'sa-east-1', 25 | 'us-gov-east-1', 26 | 'us-gov-west-1', 27 | ] 28 | } -------------------------------------------------------------------------------- /src/listAllCloudFormationStackResources.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | 3 | const listAllCloudFormationStackResources = async ( 4 | config, 5 | { 6 | region = null, 7 | stackName = null, 8 | nextToken = null, 9 | }, 10 | resources = []) => { 11 | 12 | if (!stackName) { 13 | throw new Error(`Missing "stackName" param.`) 14 | } 15 | if (!config.region && !region) { 16 | throw new Error(`Either config.region must be set or the 'region' parameter must be submitted`) 17 | } 18 | 19 | config.region = config.region || region 20 | 21 | // Fetch stacks 22 | const cloudformation = new AWS.CloudFormation(config) 23 | 24 | const params = { 25 | StackName: stackName, 26 | NextToken: nextToken, 27 | } 28 | 29 | const res = await cloudformation.listStackResources(params).promise(); 30 | 31 | // Concatenate stacks 32 | if (res.StackResourceSummaries && res.StackResourceSummaries.length) { 33 | resources = resources.concat(res.StackResourceSummaries); 34 | } 35 | 36 | // If NextToken, call again... 37 | if (res.NextToken) { 38 | return await listAllCloudFormationStackResources( 39 | config, 40 | { 41 | stackName, 42 | nextToken: res.NextToken, 43 | }, 44 | resources 45 | ); 46 | } 47 | 48 | return resources 49 | } 50 | 51 | module.exports = listAllCloudFormationStackResources 52 | -------------------------------------------------------------------------------- /src/listAllCloudFormationStacksInARegion.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const utils = require('./utils') 3 | 4 | const listAllCloudFormationStacksInARegion = async ( 5 | config, 6 | { 7 | region = null, 8 | nextToken = null, 9 | stackStatusFilter = null, 10 | retry = 0 11 | }, 12 | stacks = []) => { 13 | 14 | if (!region) { 15 | throw new Error(`Missing "region" param.`) 16 | } 17 | 18 | // If this is a retry attempt, and it's greater than 6, throw an error 19 | if (retry > 6) { 20 | throw new Error('AWS CloudFormation Rate Limit has been exceeded and retrying 6 times has failed.') 21 | } 22 | 23 | // If this is a retry attempt, add a sleep, and jitter it 24 | if (retry > 0) { 25 | let randomMs = Math.random() * (0.8 - 3.5) + 3.5 26 | randomMs = parseInt(randomMs * 1000) 27 | await utils.sleep(randomMs) 28 | } 29 | 30 | // Override region configuration 31 | config.region = region 32 | 33 | // Default to Stacks with an Active state, defined by these status values 34 | stackStatusFilter = stackStatusFilter || [ 35 | 'CREATE_COMPLETE', 36 | 'UPDATE_COMPLETE', 37 | 'ROLLBACK_COMPLETE', 38 | 'IMPORT_COMPLETE', 39 | 'IMPORT_ROLLBACK_COMPLETE', 40 | ] 41 | 42 | // Fetch stacks 43 | const cloudformation = new AWS.CloudFormation(config) 44 | 45 | const params = { 46 | NextToken: nextToken, 47 | StackStatusFilter: stackStatusFilter, 48 | } 49 | 50 | let res 51 | try { 52 | res = await cloudformation.listStacks(params).promise(); 53 | } catch (error) { 54 | // Retry, if retryable 55 | if ((error.message && error.message.includes('Rate exceeded')) || 56 | error.code === 'Throttling' || 57 | error.retryable === true) { 58 | return await listAllCloudFormationStacksInARegion( 59 | config, 60 | { 61 | region, 62 | nextToken, 63 | stackStatusFilter, 64 | retry: retry + 1, 65 | }, 66 | stacks 67 | ); 68 | } 69 | throw error 70 | } 71 | 72 | // Concatenate stacks 73 | if (res.StackSummaries && res.StackSummaries.length) { 74 | 75 | // Add region to each 76 | res.StackSummaries.forEach((summary) => { 77 | summary.Region = region 78 | }) 79 | 80 | stacks = stacks.concat(res.StackSummaries); 81 | } 82 | 83 | // If NextToken, call again... 84 | if (res.NextToken) { 85 | return await listAllCloudFormationStacksInARegion( 86 | config, 87 | { 88 | region, 89 | nextToken: res.NextToken, 90 | stackStatusFilter, 91 | }, 92 | stacks 93 | ); 94 | } 95 | 96 | return stacks 97 | } 98 | 99 | module.exports = listAllCloudFormationStacksInARegion 100 | -------------------------------------------------------------------------------- /src/listAllCloudFormationStacksInAllRegions.js: -------------------------------------------------------------------------------- 1 | const listAllAwsRegions = require('./listAllAwsRegions') 2 | const listAllCloudFormationStacksInARegion = require('./listAllCloudFormationStacksInARegion') 3 | 4 | const listAllCloudFormationStacksInAllRegions = async ( 5 | config, 6 | { 7 | regions, 8 | stackStatusFilter = null 9 | }) => { 10 | 11 | // Default to Stacks with an Active state, defined by these status values 12 | stackStatusFilter = stackStatusFilter || [ 13 | 'CREATE_COMPLETE', 14 | 'UPDATE_COMPLETE', 15 | 'ROLLBACK_COMPLETE', 16 | 'IMPORT_COMPLETE', 17 | 'IMPORT_ROLLBACK_COMPLETE', 18 | ] 19 | 20 | regions = regions || listAllAwsRegions() 21 | 22 | const allRegionalStacks = await Promise.all(regions.map((region) => { 23 | return listAllCloudFormationStacksInARegion( 24 | config, 25 | { 26 | stackStatusFilter, 27 | region 28 | } 29 | ) 30 | })) 31 | 32 | return allRegionalStacks.flat() 33 | } 34 | 35 | module.exports = listAllCloudFormationStacksInAllRegions -------------------------------------------------------------------------------- /src/removeAppSyncApi.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | 3 | module.exports = async (config, params = {}) => { 4 | const appSync = new AWS.AppSync(config) 5 | 6 | try { 7 | await appSync 8 | .deleteGraphqlApi({ 9 | apiId: params.apiId 10 | }) 11 | .promise() 12 | } catch (error) { 13 | if (error.code !== 'NotFoundException') { 14 | throw error 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/removeDistribution.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const disableDistribution = require('./disableDistribution') 3 | 4 | module.exports = async (config, params = {}) => { 5 | const { distributionId } = params 6 | 7 | if (!distributionId) { 8 | throw new Error(`Missing "distributionId" param`) 9 | } 10 | const cf = new AWS.CloudFront(config) 11 | 12 | try { 13 | const getDistributionConfigRes = await cf 14 | .getDistributionConfig({ Id: distributionId }) 15 | .promise() 16 | 17 | const deleteDistributionParams = { Id: distributionId, IfMatch: getDistributionConfigRes.ETag } 18 | await cf.deleteDistribution(deleteDistributionParams).promise() 19 | 20 | // todo remove distribtution dns 21 | } catch (e) { 22 | if (e.code === 'DistributionNotDisabled') { 23 | await disableDistribution(config, params) 24 | } else if (e.code === 'NoSuchDistribution') { 25 | return 26 | } else { 27 | throw e 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/removeLambda.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | 3 | module.exports = async (config, params = {}) => { 4 | const lambda = new AWS.Lambda(config) 5 | 6 | // todo should we return if no lambdaName param 7 | try { 8 | await lambda 9 | .deleteFunction({ 10 | FunctionName: params.lambdaName 11 | }) 12 | .promise() 13 | } catch (error) { 14 | if (error.code !== 'ResourceNotFoundException') { 15 | throw error 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/removeRole.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const removeRolePolicies = require('./removeRolePolicies') 3 | 4 | module.exports = async (config, params = {}) => { 5 | const iam = new AWS.IAM(config) 6 | 7 | try { 8 | await removeRolePolicies(config, params) 9 | } catch (error) { 10 | if (error.code !== 'NoSuchEntity') { 11 | throw error 12 | } 13 | } 14 | 15 | try { 16 | await iam 17 | .deleteRole({ 18 | RoleName: params.roleName 19 | }) 20 | .promise() 21 | } catch (error) { 22 | if (error.code !== 'NoSuchEntity') { 23 | throw error 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/removeRolePolicies.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | 3 | module.exports = async (config, params = {}) => { 4 | const iam = new AWS.IAM(config) 5 | 6 | const { AttachedPolicies: managedPolicies } = await iam 7 | .listAttachedRolePolicies({ 8 | RoleName: params.roleName 9 | }) 10 | .promise() 11 | const { PolicyNames: inlinePoliciesNames } = await iam 12 | .listRolePolicies({ 13 | RoleName: params.roleName 14 | }) 15 | .promise() 16 | 17 | const promises = [] 18 | 19 | // clear managed policies 20 | for (const managedPolicy of managedPolicies) { 21 | const detachRolePolicyParams = { 22 | PolicyArn: managedPolicy.PolicyArn, 23 | RoleName: params.roleName 24 | } 25 | 26 | promises.push(iam.detachRolePolicy(detachRolePolicyParams).promise()) 27 | } 28 | 29 | // clear inline policies 30 | for (const inlinePolicyName of inlinePoliciesNames) { 31 | const deleteRolePolicyParams = { 32 | PolicyName: inlinePolicyName, 33 | RoleName: params.roleName 34 | } 35 | 36 | promises.push(iam.deleteRolePolicy(deleteRolePolicyParams).promise()) 37 | } 38 | 39 | await Promise.all(promises) 40 | } 41 | -------------------------------------------------------------------------------- /src/removeStack.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const waitForStack = require('./waitForStack') 3 | 4 | /** 5 | * Deletes the stack 6 | * @param {object} config 7 | * @param {object} params 8 | * @returns {object} 9 | */ 10 | module.exports = async (config, params) => { 11 | if (!params.stackName) { 12 | throw new Error(`"stackName" param is required.`) 13 | } 14 | const cloudformation = new AWS.CloudFormation(config) 15 | try { 16 | await cloudformation.deleteStack({ StackName: params.stackName }).promise() 17 | const waitForStackParams = { 18 | stackName: params.stackName, 19 | successEvent: /^DELETE_COMPLETE$/, 20 | failureEvent: /^DELETE_FAILED$/ 21 | } 22 | return await waitForStack(config, waitForStackParams) 23 | } catch (error) { 24 | if (error.message !== `Stack with id ${params.stackName} does not exist`) { 25 | throw error 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const AdmZip = require('adm-zip') 2 | const mergeDeep = require('merge-deep') 3 | 4 | const sleep = async (wait) => new Promise((resolve) => setTimeout(() => resolve(), wait)) 5 | 6 | const getNakedDomain = (domain) => { 7 | const domainParts = domain.split('.') 8 | const topLevelDomainPart = domainParts[domainParts.length - 1] 9 | const secondLevelDomainPart = domainParts[domainParts.length - 2] 10 | return `${secondLevelDomainPart}.${topLevelDomainPart}` 11 | } 12 | 13 | const shouldConfigureNakedDomain = (domain) => { 14 | if (!domain) { 15 | return false 16 | } 17 | if (domain.startsWith('www') && domain.split('.').length === 3) { 18 | return true 19 | } 20 | return false 21 | } 22 | 23 | const zip = (dirPath) => { 24 | const zipper = new AdmZip() 25 | 26 | zipper.addLocalFolder(dirPath) 27 | 28 | const zipFile = zipper.toBuffer() 29 | 30 | return zipFile 31 | } 32 | 33 | module.exports = { mergeDeep, sleep, zip, getNakedDomain, shouldConfigureNakedDomain } 34 | -------------------------------------------------------------------------------- /src/waitForStack.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const { head } = require('ramda') 3 | 4 | const utils = require('./utils') 5 | 6 | /** 7 | * Waits CloudFormation stack to reach certain event 8 | * @param {object} config 9 | * @param {object} params 10 | * @returns {object} 11 | */ 12 | module.exports = async (config, params) => 13 | new Promise(async (resolve, reject) => { 14 | const cloudformation = new AWS.CloudFormation(config) 15 | const inProgress = true 16 | do { 17 | try { 18 | await utils.sleep(5000) 19 | const { Stacks } = await cloudformation 20 | .describeStacks({ StackName: params.stackName }) 21 | .promise() 22 | const stackStatus = head(Stacks).StackStatus 23 | if (params.successEvent.test(stackStatus)) { 24 | return resolve(Stacks) 25 | } else if (params.failureEvent.test(stackStatus)) { 26 | return reject(new Error(`CloudFormation failed with status ${stackStatus}`)) 27 | } 28 | } catch (error) { 29 | return reject(error) 30 | } 31 | } while (inProgress) 32 | }) 33 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | const AWS = require('./src') 2 | 3 | const ex = new AWS.Extras() 4 | 5 | const test = async () => { 6 | const res = await ex.listAllCloudFormationStacksInARegion({ region: 'us-east-1' }) 7 | } 8 | 9 | test().catch((error) => { console.log(error) }) 10 | 11 | --------------------------------------------------------------------------------