├── .npmignore ├── src ├── JSONDeepEquals.js ├── DefaultExpander.js ├── validationCheck.js ├── SDKAlias.js └── Composite.js ├── .gitignore ├── lib ├── lambda.trust.json ├── lambda.regions.json ├── cfn-template.json └── metaschema.json ├── test-helpers ├── context.js ├── test.schema.json └── https │ ├── key.pem │ ├── server.js │ └── key-cert.pem ├── LICENSE ├── package.json ├── test ├── environment.js ├── index.js ├── errors.js ├── delete.js ├── create.js ├── expander.js ├── validation.js ├── async.js ├── update.js ├── longRunning.js └── sdk.alias.js ├── index.js ├── deploy.js └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | coverage 3 | node_modules 4 | example 5 | test 6 | test-helpers 7 | -------------------------------------------------------------------------------- /src/JSONDeepEquals.js: -------------------------------------------------------------------------------- 1 | var _ = require('underscore'); 2 | 3 | module.exports = function JSONDeepEquals(a, b) { 4 | return _.isEqual(a, b); 5 | }; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | coverage 3 | node_modules 4 | .npm 5 | .node_repl_history 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pids 10 | *.pid 11 | *.seed 12 | -------------------------------------------------------------------------------- /lib/lambda.trust.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": { 4 | "Effect": "Allow", 5 | "Principal": { 6 | "Service": "lambda.amazonaws.com" 7 | }, 8 | "Action": "sts:AssumeRole" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test-helpers/context.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | done: function() { 4 | this.callback && this.callback(); 5 | }, 6 | // Sample value 7 | invokedFunctionArn: 'arn:aws:lambda:fake-region-1:012345678910' + 8 | ':function:CfnLambdaResource-TestFunction' 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /test-helpers/test.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "required": [ 4 | "name" 5 | ], 6 | "properties": { 7 | "name": { 8 | "type": "string" 9 | }, 10 | "cloneFrom": { 11 | "type": "string" 12 | }, 13 | "description": { 14 | "type": "string" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test-helpers/https/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXQIBAAKBgQCyjsfMjqTh6OkVglTo15ZW14FJ+X7uixgMk5vVN6PaUs/z3/OE 3 | JExwuCcNA0qrjl4ut7xsW5qZ9MU3pVp7VY6LOrds5q/QBHoTLIw7h1iOuOU3/Ytr 4 | AbXCuskm+FlyixmjsuaH/X9vFuh14sRSPIKCxVzYoTsy6ctXYNCHuinl/wIDAQAB 5 | AoGBAJ5ty193rVp40t7vzjDuoIkbG68sPXCgX81A50Ku5KZhVfv32FSF9IELFDMa 6 | mZVQc8aV5gxq1ukFYjt2bqsCBb+N4TEPwsyg8ZCi0hHw+LQ9dF84iuIPp+J2/IJ9 7 | k8S9Dvm2jABEtkphESx9Sn/0Zpbz2oQUT4oMnBRMrfjig2OZAkEA3Ab/8DNdjIKX 8 | jFXsm30r++xld0X/JDv9AamgUKwWb1OvmEYx7MReYmhh87t0dFDrjcemllJLpbGO 9 | nkeKhDxmjQJBAM/AHcjNWrEi81LAcjB+RbG9EIHnSOg0a2S/6khlwsVQa+ONcQlG 10 | XSOw/u1sYiXC/o3+H+M1uaI1J6SuQO6SMbsCQHriRernJSYmgXFVQ9ILdJc8jeax 11 | Zy/beRCGpgyoL9d5S6al/ZgYjAY1+g7f8MhNsWD70mQ+DhW6NsbbedckzLkCQQCo 12 | yp+oaWh5KTtnDaL5UW7QtRr3YHH077odtmvkfIFeDTRLQr0HWxsLh5/oSToJEj/+ 13 | H+3KjfkQGH3oKAfrje5HAkA0Qic/bftSDZvB+vzwIpyR0bbtQiYvl7vIA1UG1NSb 14 | LDeyKyQ0VRxrjEZp/MvnjSXfMvN/5EHqw5Cs4/TdCX/k 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2019 Andrew Templeton 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test-helpers/https/server.js: -------------------------------------------------------------------------------- 1 | var https = require('https'); 2 | var fs = require('fs'); 3 | var path = require('path'); 4 | var qs = require('querystring'); 5 | 6 | var options = { 7 | key: fs.readFileSync(path.resolve(__dirname, 'key.pem')), 8 | cert: fs.readFileSync(path.resolve(__dirname, 'key-cert.pem')) 9 | }; 10 | 11 | var port = 13002; 12 | var handler = null; 13 | var server = https.createServer(options, function(request, response) { 14 | var body = []; 15 | request.on('data', function(data) { 16 | body.push(data); 17 | }); 18 | request.on('end', function () { 19 | response.writeHead(200); 20 | response.end('OK'); 21 | handler && handler({ 22 | url: request.url, 23 | body: JSON.parse(body.join('')) 24 | }); 25 | }); 26 | }); 27 | 28 | module.exports = { 29 | listening: false, 30 | port: port, 31 | on: function(callback, setHandler) { 32 | var port = this.port; 33 | handler = setHandler; 34 | if (!this.listening) { 35 | this.listening = true 36 | server.listen(port, () => { 37 | console.log('LISTENING TO PORT: %s', port); 38 | callback(); 39 | }); 40 | } else { 41 | callback(); 42 | } 43 | return this; 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfn-lambda", 3 | "version": "5.1.0", 4 | "description": "CloudFormation custom resource helper for Lambda Node.js runtime", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "node ./node_modules/istanbul/lib/cli cover -x deploy.js ./node_modules/mocha/bin/_mocha" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/andrew-templeton/cfn-lambda.git" 12 | }, 13 | "keywords": [ 14 | "aws", 15 | "cloudformation", 16 | "lambda", 17 | "backed", 18 | "custom", 19 | "resource", 20 | "helper", 21 | "toolkit", 22 | "tool" 23 | ], 24 | "author": "Andrew Templeton (https://github.com/andrew-templeton)", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/andrew-templeton/cfn-lambda/issues" 28 | }, 29 | "homepage": "https://github.com/andrew-templeton/cfn-lambda#readme", 30 | "dependencies": { 31 | "archiver": "^3.1.1", 32 | "async": "^1.5.2", 33 | "aws-sdk": "^2.853.0", 34 | "jsonschema": "^1.4.0", 35 | "nano-argv": "^1.0.2", 36 | "underscore": "^1.12.0" 37 | }, 38 | "devDependencies": { 39 | "istanbul": "^0.4.0", 40 | "mocha": "^6.1.4" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/lambda.regions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "US East (Ohio)", 4 | "identifier": "us-east-2" 5 | }, 6 | { 7 | "name": "US East (N. Virginia)", 8 | "identifier": "us-east-1" 9 | }, 10 | { 11 | "name": "US West (N. California)", 12 | "identifier": "us-west-1" 13 | }, 14 | { 15 | "name": "US West (Oregon)", 16 | "identifier": "us-west-2" 17 | }, 18 | { 19 | "name": "Asia Pacific (Seoul)", 20 | "identifier": "ap-northeast-2" 21 | }, 22 | { 23 | "name": "Asia Pacific (Mumbai)", 24 | "identifier": "ap-south-1" 25 | }, 26 | { 27 | "name": "Asia Pacific (Singapore)", 28 | "identifier": "ap-southeast-1" 29 | }, 30 | { 31 | "name": "Asia Pacific (Sydney)", 32 | "identifier": "ap-southeast-2" 33 | }, 34 | { 35 | "name": "Asia Pacific (Tokyo)", 36 | "identifier": "ap-northeast-1" 37 | }, 38 | { 39 | "name": "Canada (Central)", 40 | "identifier": "ca-central-1" 41 | }, 42 | { 43 | "name": "EU (Frankfurt)", 44 | "identifier": "eu-central-1" 45 | }, 46 | { 47 | "name": "EU (Ireland)", 48 | "identifier": "eu-west-1" 49 | }, 50 | { 51 | "name": "EU (London)", 52 | "identifier": "eu-west-2" 53 | }, 54 | { 55 | "name": "South America (São Paulo)", 56 | "identifier": "sa-east-1" 57 | } 58 | ] 59 | -------------------------------------------------------------------------------- /src/DefaultExpander.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | const notObject = obj => Object(obj) !== obj 4 | const oneKey = obj => Object.keys(obj).length === 1 5 | 6 | 7 | const DefaultExpander = tree => { 8 | const expanded = {} 9 | const expandEach = obj => Object.keys(obj).forEach(key => expanded[key] = DefaultExpander(obj[key])) 10 | if (notObject(tree)) { 11 | return tree 12 | } 13 | if (Array.isArray(tree)) { 14 | return tree.map(DefaultExpander) 15 | } 16 | if (tree.__default__) { 17 | const defaults = DefaultExpander(JSONExpand(tree.__default__)) 18 | if (notObject(defaults)) { 19 | if (oneKey(tree)) { 20 | // The only property was defaults and it had non-Object value 21 | // So it's a string 22 | return defaults 23 | } 24 | } else if (Array.isArray(defaults)) { 25 | if (oneKey(tree)) { 26 | // The only property was defaults and it had non-Object value 27 | // So it's a string 28 | return defaults.map(DefaultExpander) 29 | } 30 | } else { 31 | expandEach(defaults) 32 | } 33 | delete tree.__default__ 34 | } 35 | expandEach(tree) 36 | return expanded 37 | } 38 | 39 | const JSONExpand = string => JSON.parse(new Buffer(string, 'base64').toString('utf8')) 40 | 41 | module.exports = val =>DefaultExpander(JSON.parse(JSON.stringify(val))) 42 | -------------------------------------------------------------------------------- /test-helpers/https/key-cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDbzCCAtigAwIBAgIJAOgATeKuq26YMA0GCSqGSIb3DQEBBQUAMIGCMQswCQYD 3 | VQQGEwJVUzELMAkGA1UECBMCVFgxDzANBgNVBAcTBkF1c3RpbjEXMBUGA1UEChMO 4 | VHVwbGUgTGFicyBMTEMxEjAQBgNVBAMTCWxvY2FsaG9zdDEoMCYGCSqGSIb3DQEJ 5 | ARYZYS50ZW1wbGV0b25Ad2VhcmV0dXBsZS5jbzAeFw0xNTEwMjYwMzIxMTFaFw0x 6 | NTExMjUwMzIxMTFaMIGCMQswCQYDVQQGEwJVUzELMAkGA1UECBMCVFgxDzANBgNV 7 | BAcTBkF1c3RpbjEXMBUGA1UEChMOVHVwbGUgTGFicyBMTEMxEjAQBgNVBAMTCWxv 8 | Y2FsaG9zdDEoMCYGCSqGSIb3DQEJARYZYS50ZW1wbGV0b25Ad2VhcmV0dXBsZS5j 9 | bzCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAso7HzI6k4ejpFYJU6NeWVteB 10 | Sfl+7osYDJOb1Tej2lLP89/zhCRMcLgnDQNKq45eLre8bFuamfTFN6Vae1WOizq3 11 | bOav0AR6EyyMO4dYjrjlN/2LawG1wrrJJvhZcosZo7Lmh/1/bxbodeLEUjyCgsVc 12 | 2KE7MunLV2DQh7op5f8CAwEAAaOB6jCB5zAdBgNVHQ4EFgQUEK1YAF1GzQqK8Pob 13 | 7WWNUXdymDEwgbcGA1UdIwSBrzCBrIAUEK1YAF1GzQqK8Pob7WWNUXdymDGhgYik 14 | gYUwgYIxCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJUWDEPMA0GA1UEBxMGQXVzdGlu 15 | MRcwFQYDVQQKEw5UdXBsZSBMYWJzIExMQzESMBAGA1UEAxMJbG9jYWxob3N0MSgw 16 | JgYJKoZIhvcNAQkBFhlhLnRlbXBsZXRvbkB3ZWFyZXR1cGxlLmNvggkA6ABN4q6r 17 | bpgwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOBgQAQ6ekcvfXnEKpFwQhK 18 | 4KvRuY6h+79r7zCEQYsHOPqsglEisE2ohQRyI9VfN/1SDWj+nBINaouFipDUXz2i 19 | PY6x5R0+z80HNKQbcUVidCCCsLCf2EtQJXUzEB4szComisoJ7FxAnPGd/pgnO6zO 20 | pcrJsfm9de2QBhUEZadc2KUE1g== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /test/environment.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | var path = require('path'); 4 | var assert = require('assert'); 5 | 6 | var Server = require(path.resolve(__dirname, '..', 'test-helpers', 'https', 'server')); 7 | var ContextStub = require(path.resolve(__dirname, '..', 'test-helpers', 'context')); 8 | 9 | var CfnLambda = require(path.resolve(__dirname, '..', 'index')); 10 | 11 | 12 | describe('CfnLambda#Environment', function() { 13 | function HollowRequest() { 14 | return { 15 | RequestType: 'Create', 16 | ResponseURL: 'https://localhost:13002/foo/bar/taco', 17 | StackId: 'fakeStackId', 18 | RequestId: 'fakeRequestId', 19 | ResourceType: 'Custom::TestResource', 20 | LogicalResourceId: 'MyTestResource' 21 | }; 22 | } 23 | it('Should yield correct Environment object', function(done) { 24 | 25 | var expectedEnvironment = { 26 | LambdaArn: 'arn:aws:lambda:fake-region-1:012345678910' + 27 | ':function:CfnLambdaResource-TestFunction', 28 | Region: 'fake-region-1', 29 | AccountId: '012345678910', 30 | LambdaName: 'CfnLambdaResource-TestFunction' 31 | }; 32 | 33 | var actualEnvironment; 34 | var Lambda = CfnLambda({ 35 | Create: function(Params, reply) { 36 | actualEnvironment = CfnLambda.Environment; 37 | reply(); 38 | } 39 | }); 40 | 41 | Server.on(function() { 42 | Lambda(HollowRequest(), ContextStub); 43 | }, function(cfnResponse) { 44 | assert(actualEnvironment.LambdaArn === expectedEnvironment.LambdaArn); 45 | assert(actualEnvironment.Region === expectedEnvironment.Region); 46 | assert(actualEnvironment.AccountId === expectedEnvironment.AccountId); 47 | assert(actualEnvironment.LambdaName === expectedEnvironment.LambdaName); 48 | done(); 49 | }); 50 | 51 | }); 52 | 53 | }); -------------------------------------------------------------------------------- /src/validationCheck.js: -------------------------------------------------------------------------------- 1 | 2 | var path = require('path'); 3 | var fs = require('fs'); 4 | 5 | var Validator = require('jsonschema').Validator; 6 | var JSONSchema = new Validator(); 7 | 8 | var metaschema = require('../lib/metaschema.json'); 9 | 10 | 11 | module.exports = function checkIfInvalid(params, validatorObject) { 12 | var loadedSchema; 13 | var invalidations = []; 14 | if (validatorObject.Validate !== undefined) { 15 | if ('function' === typeof validatorObject.Validate) { 16 | invalidations.push(validatorObject.Validate(params)); 17 | } else { 18 | invalidations.push('FATAL: Any Lambda Validate should be a function.'); 19 | } 20 | } 21 | if (validatorObject.Schema !== undefined) { 22 | invalidations.push(jsonSchemaValidator(params, validatorObject.Schema)); 23 | } 24 | if (validatorObject.SchemaPath !== undefined) { 25 | if (Array.isArray(validatorObject.SchemaPath) && 26 | validatorObject.SchemaPath.every(function(pathElement) { 27 | return 'string' === typeof pathElement; 28 | })) { 29 | try { 30 | loadedSchema = JSON.parse(fs.readFileSync(path 31 | .resolve.apply(path, validatorObject.SchemaPath)).toString()); 32 | } catch (err) { 33 | invalidations.push('FATAL: No JSON was found at SchemaPath'); 34 | } 35 | if (loadedSchema) { 36 | invalidations.push(jsonSchemaValidator(params, loadedSchema)); 37 | } 38 | } else { 39 | invalidations.push('FATAL: Any Lambda SchemaPath should be an Array of String.'); 40 | } 41 | 42 | } 43 | return invalidations.filter(function(invalidation) { 44 | return !!invalidation; 45 | }).join('\n'); 46 | }; 47 | 48 | function jsonSchemaValidator(params, schema) { 49 | if (Object.prototype.toString.call(schema) === '[object Object]') { 50 | if (JSONSchema.validate(schema, metaschema).errors.length) { 51 | return 'The custom resource\'s schema was an ' + 52 | 'object, but was not valid JSONSchema v4.'; 53 | } else { 54 | const errs = JSONSchema.validate(params, schema).errors.map(function(err) { 55 | // Guaranteed-order serialization w/ Array, hence weird format 56 | return [ 57 | ['property ==>', err.property], 58 | ['message ==>', err.message], 59 | ['schema ==>', err.schema], 60 | ['instance ==>', err.instance], 61 | ['name ==>', err.name], 62 | ['argument ==>', err.argument], 63 | ['stack ==>', err.stack] 64 | ] 65 | }); 66 | return errs.length && JSON.stringify(errs) 67 | } 68 | } else { 69 | return 'FATAL: Any Lambda Schema should be a plain Object.'; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 2 | var path = require('path'); 3 | var assert = require('assert'); 4 | 5 | var CfnLambda = require(path.resolve(__dirname, '..', 'index')); 6 | var JSONDeepEquals = CfnLambda.JSONDeepEquals; 7 | 8 | describe('Sanity', function() { 9 | it('should produce a lambda handler when provided object', function() { 10 | var resource = {}; 11 | var lambda = CfnLambda(resource); 12 | assert('function' == typeof lambda); 13 | assert(lambda.length == 2); 14 | }); 15 | }); 16 | 17 | describe('JSONDeepEquals', function() { 18 | describe('Array types', function(done) { 19 | it('should check actual array types on primitive elements', function(done) { 20 | var a = [ 21 | 0, 22 | 1, 23 | 2 24 | ]; 25 | var b = { 26 | '0': 0, 27 | '1': 1, 28 | '2': 2 29 | }; 30 | assert(!JSONDeepEquals(a, b)); 31 | done(); 32 | }); 33 | it('should check actual array types on complex elements', function(done) { 34 | var a = [ 35 | 0, 36 | { 37 | foo: 'bar' 38 | }, 39 | 2 40 | ]; 41 | var b = { 42 | '0': 0, 43 | '1': { 44 | foo: 'bar' 45 | }, 46 | '2': 2 47 | }; 48 | assert(!JSONDeepEquals(a, b)); 49 | done(); 50 | }); 51 | it('should check actual array types on complex objects', function(done) { 52 | var a = { 53 | "Baz": "Qux", 54 | "DeepExpansion": { 55 | "Arr": { 56 | "0": { 57 | "Existing": "Element" 58 | }, 59 | "1": { 60 | "deepest": "variable", 61 | "Foo": "Bar", 62 | "Overlap": "NewValue" 63 | } 64 | } 65 | } 66 | }; 67 | var b = { 68 | "Baz": "Qux", 69 | "DeepExpansion": { 70 | "Arr": [ 71 | { 72 | "Existing": "Element" 73 | }, 74 | { 75 | "Foo": "Bar", 76 | "Overlap": "NewValue", 77 | "deepest": "variable" 78 | } 79 | ] 80 | } 81 | }; 82 | assert(!JSONDeepEquals(a, b)); 83 | done(); 84 | }); 85 | }); 86 | describe('NaN equality corner cases', function() { 87 | it('should find that NaNs are equal', function(done) { 88 | var a = { 89 | foo: NaN 90 | }; 91 | var b = { 92 | foo: NaN 93 | }; 94 | assert(JSONDeepEquals(a, b)); 95 | done(); 96 | }); 97 | it('should find that single NaNs are unequal', function(done) { 98 | var a = { 99 | foo: NaN 100 | }; 101 | var b = { 102 | foo: -0 103 | }; 104 | assert(!JSONDeepEquals(a, b)); 105 | done(); 106 | }); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /lib/cfn-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion": "2010-09-09", 3 | "Parameters": { 4 | "ResourceTypeName": { 5 | "Type": "String" 6 | }, 7 | "ResourceTypeVersion": { 8 | "Type": "String" 9 | }, 10 | "CodeBucket": { 11 | "Type": "String" 12 | }, 13 | "LambdaRuntimeVersion": { 14 | "Type": "String", 15 | "Default": "nodejs14.x" 16 | } 17 | }, 18 | "Resources": { 19 | "ServiceLambda": { 20 | "Type": "AWS::Lambda::Function", 21 | "DependsOn": [ 22 | "ServiceLambdaRole" 23 | ], 24 | "Properties": { 25 | "Code": { 26 | "S3Bucket": { 27 | "Ref": "CodeBucket" 28 | }, 29 | "S3Key": { 30 | "Fn::Sub": "${ResourceTypeVersion}.zip" 31 | } 32 | }, 33 | "FunctionName": { 34 | "Fn::Sub": "${ResourceTypeName}-${ResourceTypeVersion}" 35 | }, 36 | "Description": { 37 | "Fn::Sub": "CloudFormation Custom Resource service for Custom::${ResourceTypeName}, version ${ResourceTypeVersion}" 38 | }, 39 | "Role": { 40 | "Fn::GetAtt": [ 41 | "ServiceLambdaRole", 42 | "Arn" 43 | ] 44 | }, 45 | "Handler": "index.handler", 46 | "Runtime": { 47 | "Ref": "LambdaRuntimeVersion" 48 | }, 49 | "Timeout": 300, 50 | "MemorySize": 128 51 | } 52 | }, 53 | "ServiceLambdaRole": { 54 | "Type": "AWS::IAM::Role", 55 | "Properties": { 56 | "AssumeRolePolicyDocument": { 57 | "Version": "2012-10-17", 58 | "Statement": { 59 | "Effect": "Allow", 60 | "Principal": { 61 | "Service": "lambda.amazonaws.com" 62 | }, 63 | "Action": "sts:AssumeRole" 64 | } 65 | } 66 | } 67 | }, 68 | "ServiceLambdaRolePolicy": { 69 | "Type": "AWS::IAM::Policy", 70 | "DependsOn": [ 71 | "ServiceLambdaRole" 72 | ], 73 | "Properties": { 74 | "PolicyDocument": "THIS IS REPLACED IN THE SCRIPT WITH THE JSON FOR THE SPECIFIC SERVICE", 75 | "PolicyName": { 76 | "Fn::Sub": "${ResourceTypeName}-${ResourceTypeVersion}-lambda-role-policy" 77 | }, 78 | "Roles": [ 79 | { 80 | "Ref": "ServiceLambdaRole" 81 | } 82 | ] 83 | } 84 | } 85 | }, 86 | "Outputs": { 87 | "ServiceToken": { 88 | "Description": "The Lambda function ARN to use for ServiceToken in Properties for any future stack resources invoking this custom resource.", 89 | "Value": { 90 | "Fn::GetAtt": [ 91 | "ServiceLambda", 92 | "Arn" 93 | ] 94 | }, 95 | "Export": { 96 | "Name": { 97 | "Fn::Sub": "${ResourceTypeName}-${ResourceTypeVersion}-ServiceToken" 98 | } 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /test/errors.js: -------------------------------------------------------------------------------- 1 | 2 | var path = require('path'); 3 | var assert = require('assert'); 4 | 5 | var Server = require(path.resolve(__dirname, '..', 'test-helpers', 'https', 'server')); 6 | var ContextStub = require(path.resolve(__dirname, '..', 'test-helpers', 'context')); 7 | 8 | var CfnLambda = require(path.resolve(__dirname, '..', 'index')); 9 | 10 | 11 | describe('Severe CloudFormation Errors', function() { 12 | it('should still terminate lambda on signed url connection errors', function(done) { 13 | var expectedUrl = '/foo/bar/taco'; 14 | var expectedStackId = 'fakeStackId'; 15 | var expectedRequestId = 'fakeRequestId'; 16 | var expectedLogicalResourceId = 'MyTestResource'; 17 | function HollowRequest() { 18 | return { 19 | RequestType: 'Create', 20 | // Broke port intentionally! 21 | ResponseURL: 'https://localhost-just-kidding' + expectedUrl, 22 | StackId: expectedStackId, 23 | RequestId: expectedRequestId, 24 | ResourceType: 'Custom::TestResource', 25 | LogicalResourceId: expectedLogicalResourceId 26 | }; 27 | } 28 | ContextStub.callback = function() { 29 | // means it terminated, instrumented context.done() stub. 30 | done(); 31 | }; 32 | var CfnRequest = HollowRequest(); 33 | var Lambda = CfnLambda({ 34 | Create: function(Params, reply) { 35 | reply(); 36 | } 37 | }); 38 | Server.on(function() { 39 | Lambda(CfnRequest, ContextStub); 40 | }, function(cfnResponse) { 41 | // never hits 42 | }); 43 | 44 | }); 45 | 46 | 47 | it('should get mad when bad RequestType sent', function(done) { 48 | ContextStub.callback = null; 49 | var expectedUrl = '/foo/bar/taco'; 50 | var expectedStackId = 'fakeStackId'; 51 | var expectedRequestId = 'fakeRequestId'; 52 | var expectedLogicalResourceId = 'MyTestResource'; 53 | function HollowRequest() { 54 | return { 55 | RequestType: 'Unicorns!!', 56 | ResponseURL: 'https://localhost:13002' + expectedUrl, 57 | StackId: expectedStackId, 58 | RequestId: expectedRequestId, 59 | ResourceType: 'Custom::TestResource', 60 | LogicalResourceId: expectedLogicalResourceId 61 | }; 62 | } 63 | var CfnRequest = HollowRequest(); 64 | var expectedStatus = 'FAILED'; 65 | var expectedPhysicalId = [ 66 | expectedStackId, 67 | expectedLogicalResourceId, 68 | expectedRequestId 69 | ].join('/'); 70 | var expectedReason = 'The impossible happend! ' + 71 | 'CloudFormation sent an unknown RequestType.'; 72 | var Lambda = CfnLambda({ 73 | Create: function(Params, reply) { 74 | reply(); 75 | } 76 | }); 77 | Server.on(function() { 78 | Lambda(CfnRequest, ContextStub); 79 | }, function(cfnResponse) { 80 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 81 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 82 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 83 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 84 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 85 | assert(expectedPhysicalId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 86 | assert(expectedReason === cfnResponse.body.Reason, 'Reason mismatch'); 87 | done(); 88 | }); 89 | 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /lib/metaschema.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "http://json-schema.org/draft-04/schema#", 3 | "$schema": "http://json-schema.org/draft-04/schema#", 4 | "description": "Core schema meta-schema", 5 | "definitions": { 6 | "schemaArray": { 7 | "type": "array", 8 | "minItems": 1, 9 | "items": { "$ref": "#" } 10 | }, 11 | "positiveInteger": { 12 | "type": "integer", 13 | "minimum": 0 14 | }, 15 | "positiveIntegerDefault0": { 16 | "allOf": [ { "$ref": "#/definitions/positiveInteger" }, { "default": 0 } ] 17 | }, 18 | "simpleTypes": { 19 | "enum": [ "array", "boolean", "integer", "null", "number", "object", "string" ] 20 | }, 21 | "stringArray": { 22 | "type": "array", 23 | "items": { "type": "string" }, 24 | "minItems": 1, 25 | "uniqueItems": true 26 | } 27 | }, 28 | "type": "object", 29 | "properties": { 30 | "id": { 31 | "type": "string", 32 | "format": "uri" 33 | }, 34 | "$schema": { 35 | "type": "string", 36 | "format": "uri" 37 | }, 38 | "title": { 39 | "type": "string" 40 | }, 41 | "description": { 42 | "type": "string" 43 | }, 44 | "default": {}, 45 | "multipleOf": { 46 | "type": "number", 47 | "minimum": 0, 48 | "exclusiveMinimum": true 49 | }, 50 | "maximum": { 51 | "type": "number" 52 | }, 53 | "exclusiveMaximum": { 54 | "type": "boolean", 55 | "default": false 56 | }, 57 | "minimum": { 58 | "type": "number" 59 | }, 60 | "exclusiveMinimum": { 61 | "type": "boolean", 62 | "default": false 63 | }, 64 | "maxLength": { "$ref": "#/definitions/positiveInteger" }, 65 | "minLength": { "$ref": "#/definitions/positiveIntegerDefault0" }, 66 | "pattern": { 67 | "type": "string", 68 | "format": "regex" 69 | }, 70 | "additionalItems": { 71 | "anyOf": [ 72 | { "type": "boolean" }, 73 | { "$ref": "#" } 74 | ], 75 | "default": {} 76 | }, 77 | "items": { 78 | "anyOf": [ 79 | { "$ref": "#" }, 80 | { "$ref": "#/definitions/schemaArray" } 81 | ], 82 | "default": {} 83 | }, 84 | "maxItems": { "$ref": "#/definitions/positiveInteger" }, 85 | "minItems": { "$ref": "#/definitions/positiveIntegerDefault0" }, 86 | "uniqueItems": { 87 | "type": "boolean", 88 | "default": false 89 | }, 90 | "maxProperties": { "$ref": "#/definitions/positiveInteger" }, 91 | "minProperties": { "$ref": "#/definitions/positiveIntegerDefault0" }, 92 | "required": { "$ref": "#/definitions/stringArray" }, 93 | "additionalProperties": { 94 | "anyOf": [ 95 | { "type": "boolean" }, 96 | { "$ref": "#" } 97 | ], 98 | "default": {} 99 | }, 100 | "definitions": { 101 | "type": "object", 102 | "additionalProperties": { "$ref": "#" }, 103 | "default": {} 104 | }, 105 | "properties": { 106 | "type": "object", 107 | "additionalProperties": { "$ref": "#" }, 108 | "default": {} 109 | }, 110 | "patternProperties": { 111 | "type": "object", 112 | "additionalProperties": { "$ref": "#" }, 113 | "default": {} 114 | }, 115 | "dependencies": { 116 | "type": "object", 117 | "additionalProperties": { 118 | "anyOf": [ 119 | { "$ref": "#" }, 120 | { "$ref": "#/definitions/stringArray" } 121 | ] 122 | } 123 | }, 124 | "enum": { 125 | "type": "array", 126 | "minItems": 1, 127 | "uniqueItems": true 128 | }, 129 | "type": { 130 | "anyOf": [ 131 | { "$ref": "#/definitions/simpleTypes" }, 132 | { 133 | "type": "array", 134 | "items": { "$ref": "#/definitions/simpleTypes" }, 135 | "minItems": 1, 136 | "uniqueItems": true 137 | } 138 | ] 139 | }, 140 | "allOf": { "$ref": "#/definitions/schemaArray" }, 141 | "anyOf": { "$ref": "#/definitions/schemaArray" }, 142 | "oneOf": { "$ref": "#/definitions/schemaArray" }, 143 | "not": { "$ref": "#" } 144 | }, 145 | "dependencies": { 146 | "exclusiveMaximum": [ "maximum" ], 147 | "exclusiveMinimum": [ "minimum" ] 148 | }, 149 | "default": {} 150 | } 151 | -------------------------------------------------------------------------------- /test/delete.js: -------------------------------------------------------------------------------- 1 | 2 | var path = require('path'); 3 | var assert = require('assert'); 4 | 5 | var Server = require(path.resolve(__dirname, '..', 'test-helpers', 'https', 'server')); 6 | var ContextStub = require(path.resolve(__dirname, '..', 'test-helpers', 'context')); 7 | 8 | var CfnLambda = require(path.resolve(__dirname, '..', 'index')); 9 | 10 | 11 | describe('Delete', function() { 12 | var expectedUrl = '/foo/bar/taco'; 13 | var expectedStackId = 'fakeStackId'; 14 | var expectedRequestId = 'fakeRequestId'; 15 | var expectedLogicalResourceId = 'MyTestResource'; 16 | function HollowRequest() { 17 | return { 18 | RequestType: 'Delete', 19 | ResponseURL: 'https://localhost:13002' + expectedUrl, 20 | StackId: expectedStackId, 21 | RequestId: expectedRequestId, 22 | ResourceType: 'Custom::TestResource', 23 | LogicalResourceId: expectedLogicalResourceId, 24 | PhysicalResourceId: 'someFakeId' 25 | }; 26 | } 27 | it('Should work with unchanged PhysicalResourceId', function(done) { 28 | var CfnRequest = HollowRequest(); 29 | var expectedStatus = 'SUCCESS'; 30 | var Lambda = CfnLambda({ 31 | Delete: function(PhysicalId, Params, reply) { 32 | reply(); 33 | } 34 | }); 35 | 36 | Server.on(function() { 37 | Lambda(CfnRequest, ContextStub); 38 | }, function(cfnResponse) { 39 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 40 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 41 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 42 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 43 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 44 | console.log(cfnResponse.body.PhysicalResourceId) 45 | assert(CfnRequest.PhysicalResourceId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 46 | done(); 47 | }); 48 | 49 | }); 50 | 51 | it('Should pass with good ResourceProperties', function(done) { 52 | var CfnRequest = HollowRequest(); 53 | var expectedStatus = 'SUCCESS'; 54 | CfnRequest.ResourceProperties = { 55 | Foo: ['array', 'of', 'string', 'values'] 56 | }; 57 | var expectedPhysicalId = 'someValueProvided'; 58 | var expectedData = { 59 | foo: 'bar' 60 | }; 61 | function isString(thing) { 62 | return 'string' === typeof thing; 63 | } 64 | var Lambda = CfnLambda({ 65 | Delete: function(PhysicalId, Params, reply) { 66 | reply(null, expectedPhysicalId, expectedData); 67 | }, 68 | Validate: function(ResourceProperties) { 69 | if (!ResourceProperties || 70 | !Array.isArray(ResourceProperties.Foo) || 71 | !ResourceProperties.Foo.every(isString)) { 72 | console.log('FAILED VALIDATION: %j', ResourceProperties); 73 | return 'Propery Foo must be an Array of String'; 74 | } 75 | } 76 | }); 77 | 78 | Server.on(function() { 79 | Lambda(CfnRequest, ContextStub); 80 | }, function(cfnResponse) { 81 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 82 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 83 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 84 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 85 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 86 | assert(expectedPhysicalId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 87 | assert(JSON.stringify(expectedData) === 88 | JSON.stringify(cfnResponse.body.Data), 'Bad Data payload'); 89 | done(); 90 | }); 91 | 92 | }); 93 | 94 | it('Should short circuit with bad ResourceProperties', function(done) { 95 | var CfnRequest = HollowRequest(); 96 | var expectedStatus = 'SUCCESS'; 97 | var deleteWasRun = false; 98 | CfnRequest.ResourceProperties = { 99 | Foo: ['array', 'of', 'NOT ALL', {string: 'values'}] 100 | }; 101 | var Lambda = CfnLambda({ 102 | Delete: function(PhysicalId, Params, reply) { 103 | deleteWasRun = true; 104 | reply(); 105 | }, 106 | Validate: function(ResourceProperties) { 107 | if (!ResourceProperties || 108 | !Array.isArray(ResourceProperties.Foo) || 109 | !ResourceProperties.Foo.every(isString)) { 110 | console.log('FAILED VALIDATION: %j', ResourceProperties); 111 | return 'Propery Foo must be an Array of String'; 112 | } 113 | function isString(thing) { 114 | return 'string' === typeof thing; 115 | } 116 | } 117 | }); 118 | 119 | Server.on(function() { 120 | Lambda(CfnRequest, ContextStub); 121 | }, function(cfnResponse) { 122 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 123 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 124 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 125 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 126 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 127 | assert(!deleteWasRun, 'Delete should not have run'); 128 | done(); 129 | }); 130 | 131 | }); 132 | 133 | it('Should fail with correct messaging', function(done) { 134 | var CfnRequest = HollowRequest(); 135 | var expectedStatus = 'FAILED'; 136 | var expectedReason = 'You done goofed, son!!'; 137 | var Lambda = CfnLambda({ 138 | Delete: function(PhysicalId, Params, reply) { 139 | reply(expectedReason); 140 | } 141 | }); 142 | 143 | Server.on(function() { 144 | Lambda(CfnRequest, ContextStub); 145 | }, function(cfnResponse) { 146 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 147 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 148 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 149 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 150 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 151 | assert(expectedReason === cfnResponse.body.Reason, 'Bad error Reason'); 152 | done(); 153 | }); 154 | 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /src/SDKAlias.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = options => (...args) => { 3 | console.log('Using cfn-lambda SDKAlias to define an operation') 4 | const { method } = options 5 | switch (args.length) { 6 | // Create 7 | case 2: 8 | console.log('Aliasing method %s as CREATE operation.', method) 9 | return SimpleAlias({ 10 | options, 11 | physicalId: null, 12 | params: args[0], 13 | reply: args[1] 14 | }) 15 | // Delete 16 | case 3: 17 | console.log('Aliasing method %s as DELETE or NOOPUPDATE operation.', method) 18 | return SimpleAlias({ 19 | options, 20 | physicalId: args[0], 21 | params: args[1], 22 | reply: args[2] 23 | }) 24 | // Update 25 | case 4: 26 | console.log('Aliasing method %s as UPDATE operation.', method) 27 | return SimpleAlias({ 28 | options, 29 | physicalId: args[0], 30 | params: args[1], 31 | reply: args[3] 32 | }) 33 | default: 34 | throw new Error('Could not determine cfn-lambda SDKAlias method signature at runtime.') 35 | } 36 | } 37 | 38 | const SimpleAlias = ({ 39 | options, 40 | options: { 41 | returnPhysicalId, 42 | api, 43 | method, 44 | ignoreErrorCodes 45 | }, 46 | physicalId, 47 | params: { ServiceToken, ...params }={}, 48 | reply 49 | }) => { 50 | var usedParams = usableParams({ params, options, physicalId }) 51 | var physicalIdFunction = 'function' === typeof returnPhysicalId 52 | ? returnPhysicalId 53 | : 'string' === typeof returnPhysicalId 54 | ? accessFunction(returnPhysicalId) 55 | : noop; 56 | api[method](usedParams, function(err, data) { 57 | if (!err || isIgnorable(ignoreErrorCodes, err)) { 58 | console.log('Aliased method succeeded: %j', data); 59 | return reply(null, physicalIdFunction(data, params), 60 | attrsFrom({ options, data })); 61 | } 62 | console.log('Aliased method had error: %j', err); 63 | reply(err.message); 64 | }); 65 | } 66 | 67 | const attrsFrom = ({ options: { returnAttrs }, data }) => ((Array.isArray(returnAttrs) && returnAttrs.every(isString) 68 | ? keyFilter.bind(null, returnAttrs) 69 | : 'function' === typeof returnAttrs 70 | ? returnAttrs 71 | : noop)(data)) 72 | 73 | 74 | const forcePaths = (params, pathSet, translator) => { 75 | pathSet.forEach(path => { 76 | const pathTokens = path.split('.') 77 | const lastToken = pathTokens.pop() 78 | const intermediate = pathTokens.reduce((obj, key, index) => { 79 | if ('*' === key && obj != null) { 80 | return forcePaths(obj, Object.keys(obj).map(indexOrElement => [indexOrElement].concat(pathTokens.slice(index + 1)).concat(lastToken).join('.')), translator) 81 | } 82 | return obj == null 83 | ? undefined 84 | : obj[key] 85 | }, params) 86 | if (intermediate) { 87 | if (lastToken === '*') { 88 | if (Array.isArray(intermediate)) { 89 | intermediate.forEach((value, index) => intermediate[index] = translator(value)) 90 | } else { 91 | Object.keys(intermediate).forEach(key => intermediate[key] = translator(intermediate[key])) 92 | } 93 | } else if (intermediate[lastToken] !== undefined) { 94 | intermediate[lastToken] = translator(intermediate[lastToken]) 95 | } 96 | } 97 | }) 98 | return params 99 | } 100 | 101 | const forceNum = (params, pathSet) => forcePaths(params, pathSet, value => +value) 102 | 103 | const forceBoolean = (params, pathSet) => forcePaths(params, pathSet, value => ({ 104 | '0': false, 105 | 'false': false, 106 | '': false, 107 | 'null': false, 108 | 'undefined': false, 109 | '1': true, 110 | 'true': true 111 | })[value]) 112 | 113 | 114 | const chain = functors => starting => functors.reduce((current, functor) => functor(current), starting) 115 | const defaultToObject = params => params || {} 116 | const forceBoolsWithin = ({ forceBools }) => params => Array.isArray(forceBools) && forceBools.every(isString) ? forceBoolean(params, forceBools) : params 117 | const forceNumsWithin = ({ forceNums }) => params => Array.isArray(forceNums) && forceNums.every(isString) ? forceNum(params, forceNums) : params 118 | const maybeAliasPhysicalId = ({ physicalId, physicalIdAs }) => params => isString(physicalIdAs) ? addAliasedPhysicalId(params, physicalIdAs, physicalId) : params 119 | const filterToKeys = ({ keys }) => params => Array.isArray(keys) && keys.every(isString) ? keyFilter(keys, params) : params 120 | const mapWithKeys = ({ mapKeys }) => params => Object(mapKeys) === mapKeys ? useKeyMap(params, mapKeys) : params 121 | const maybeDowncase = ({ downcase }) => params => downcase ? downcaseKeys(params) : params 122 | const logWithMethod = ({ method }) => params => (console.log('Calling aliased method %s with params: %j', method, params) || params) 123 | 124 | const usableParams = ({ 125 | params, 126 | options: { forceBools, forceNums, physicalIdAs, keys, mapKeys, downcase, method }, 127 | physicalId 128 | }) => chain([ 129 | defaultToObject, 130 | forceBoolsWithin({ forceBools }), 131 | forceNumsWithin({ forceNums }), 132 | maybeAliasPhysicalId({ physicalId, physicalIdAs }), 133 | filterToKeys({ keys }), 134 | mapWithKeys({ mapKeys }), 135 | maybeDowncase({ downcase }), 136 | logWithMethod({ method }) 137 | ])(params) 138 | 139 | const addAliasedPhysicalId = (params, physcialIdAlias, physicalId) => ({ ...params, [physcialIdAlias]: physicalId }) 140 | 141 | const downcaseKeys = hash => Object.keys(hash).reduce((dced, key) => ({ ...dced, [key[0].toLowerCase() + key.slice(1, key.length)]: hash[key] }), {}) 142 | 143 | const isString = obj => 'string' === typeof obj 144 | 145 | const isIgnorable = (ignorableErrorCodes, errObject) => Array.isArray(ignorableErrorCodes) && !!~ignorableErrorCodes.indexOf(errObject.statusCode) 146 | 147 | 148 | const accessFunction = key => { 149 | let actualKey = key 150 | const getDataSimple = data => data == null ? undefined : data[actualKey] 151 | const getDataRecursive = data => { 152 | if (actualKey.includes('.')) { 153 | const pathTokens = actualKey.split('.') 154 | const firstElem = pathTokens[0] 155 | const childData = data[firstElem] 156 | const childPath = pathTokens.slice(1).join('.') 157 | actualKey = childPath 158 | return getDataRecursive(childData) 159 | } 160 | return getDataSimple(data) 161 | } 162 | return getDataRecursive 163 | } 164 | 165 | const useKeyMap = (params, keyMap) => Object.keys(params).reduce((mapped, key) => ({ ...mapped, [keyMap[key] ? keyMap[key] : key]: params[key] }), {}) 166 | 167 | const noop = () => undefined 168 | 169 | const keyFilter = (includedKeySet, hash) => includedKeySet.reduce((fHash, key) => ({ ...fHash, [key]: accessFunction(key)(hash) }), {}) 170 | -------------------------------------------------------------------------------- /test/create.js: -------------------------------------------------------------------------------- 1 | 2 | var path = require('path'); 3 | var assert = require('assert'); 4 | 5 | var Server = require(path.resolve(__dirname, '..', 'test-helpers', 'https', 'server')); 6 | var ContextStub = require(path.resolve(__dirname, '..', 'test-helpers', 'context')); 7 | 8 | var CfnLambda = require(path.resolve(__dirname, '..', 'index')); 9 | 10 | 11 | describe('Create', function() { 12 | var expectedUrl = '/foo/bar/taco'; 13 | var expectedStackId = 'fakeStackId'; 14 | var expectedRequestId = 'fakeRequestId'; 15 | var expectedLogicalResourceId = 'MyTestResource'; 16 | function HollowRequest() { 17 | return { 18 | RequestType: 'Create', 19 | ResponseURL: 'https://localhost:13002' + expectedUrl, 20 | StackId: expectedStackId, 21 | RequestId: expectedRequestId, 22 | ResourceType: 'Custom::TestResource', 23 | LogicalResourceId: expectedLogicalResourceId 24 | }; 25 | } 26 | it('Should work with default generated PhysicalResourceId', function(done) { 27 | var CfnRequest = HollowRequest(); 28 | var expectedStatus = 'SUCCESS'; 29 | var expectedPhysicalId = [ 30 | expectedStackId, 31 | expectedLogicalResourceId, 32 | expectedRequestId 33 | ].join('/'); 34 | var Lambda = CfnLambda({ 35 | Create: function(Params, reply) { 36 | reply(); 37 | } 38 | }); 39 | 40 | Server.on(function() { 41 | Lambda(CfnRequest, ContextStub); 42 | }, function(cfnResponse) { 43 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 44 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 45 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 46 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 47 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 48 | assert(expectedPhysicalId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 49 | done(); 50 | }); 51 | 52 | }); 53 | 54 | it('Should work with a provided PhysicalResourceId', function(done) { 55 | var CfnRequest = HollowRequest(); 56 | var expectedStatus = 'SUCCESS'; 57 | var expectedPhysicalId = 'someValueProvided'; 58 | var Lambda = CfnLambda({ 59 | Create: function(Params, reply) { 60 | reply(null, expectedPhysicalId); 61 | } 62 | }); 63 | 64 | Server.on(function() { 65 | Lambda(CfnRequest, ContextStub); 66 | }, function(cfnResponse) { 67 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 68 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 69 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 70 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 71 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 72 | assert(expectedPhysicalId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 73 | done(); 74 | }); 75 | 76 | }); 77 | 78 | it('Should work with a provided Data set', function(done) { 79 | var CfnRequest = HollowRequest(); 80 | var expectedStatus = 'SUCCESS'; 81 | var expectedPhysicalId = 'someValueProvided'; 82 | var expectedData = { 83 | foo: 'bar' 84 | }; 85 | var Lambda = CfnLambda({ 86 | Create: function(Params, reply) { 87 | reply(null, expectedPhysicalId, expectedData); 88 | } 89 | }); 90 | 91 | Server.on(function() { 92 | Lambda(CfnRequest, ContextStub); 93 | }, function(cfnResponse) { 94 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 95 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 96 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 97 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 98 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 99 | assert(expectedPhysicalId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 100 | assert(JSON.stringify(expectedData) === 101 | JSON.stringify(cfnResponse.body.Data), 'Bad Data payload'); 102 | done(); 103 | }); 104 | 105 | }); 106 | 107 | it('Should pass with good ResourceProperties', function(done) { 108 | var CfnRequest = HollowRequest(); 109 | var expectedStatus = 'SUCCESS'; 110 | CfnRequest.ResourceProperties = { 111 | Foo: ['array', 'of', 'string', 'values'] 112 | }; 113 | var expectedPhysicalId = 'someValueProvided'; 114 | var expectedData = { 115 | foo: 'bar' 116 | }; 117 | function isString(thing) { 118 | return 'string' === typeof thing; 119 | } 120 | var Lambda = CfnLambda({ 121 | Create: function(Params, reply) { 122 | reply(null, expectedPhysicalId, expectedData); 123 | }, 124 | Validate: function(ResourceProperties) { 125 | if (!ResourceProperties || 126 | !Array.isArray(ResourceProperties.Foo) || 127 | !ResourceProperties.Foo.every(isString)) { 128 | console.log('FAILED VALIDATION: %j', ResourceProperties); 129 | return 'Propery Foo must be an Array of String'; 130 | } 131 | } 132 | }); 133 | 134 | Server.on(function() { 135 | Lambda(CfnRequest, ContextStub); 136 | }, function(cfnResponse) { 137 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 138 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 139 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 140 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 141 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 142 | assert(expectedPhysicalId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 143 | assert(JSON.stringify(expectedData) === 144 | JSON.stringify(cfnResponse.body.Data), 'Bad Data payload'); 145 | done(); 146 | }); 147 | 148 | }); 149 | 150 | it('Should fail with bad ResourceProperties', function(done) { 151 | var CfnRequest = HollowRequest(); 152 | var expectedStatus = 'FAILED'; 153 | CfnRequest.ResourceProperties = { 154 | Foo: ['array', 'of', 'NOT ALL', {string: 'values'}] 155 | }; 156 | var Lambda = CfnLambda({ 157 | Create: function(Params, reply) { 158 | reply(null, expectedPhysicalId, expectedData); 159 | }, 160 | Validate: function(ResourceProperties) { 161 | if (!ResourceProperties || 162 | !Array.isArray(ResourceProperties.Foo) || 163 | !ResourceProperties.Foo.every(isString)) { 164 | console.log('FAILED VALIDATION: %j', ResourceProperties); 165 | return 'Propery Foo must be an Array of String'; 166 | } 167 | function isString(thing) { 168 | return 'string' === typeof thing; 169 | } 170 | } 171 | }); 172 | 173 | Server.on(function() { 174 | Lambda(CfnRequest, ContextStub); 175 | }, function(cfnResponse) { 176 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 177 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 178 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 179 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 180 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 181 | done(); 182 | }); 183 | 184 | }); 185 | 186 | it('Should fail with correct messaging', function(done) { 187 | var CfnRequest = HollowRequest(); 188 | var expectedStatus = 'FAILED'; 189 | var expectedReason = 'You done goofed, son!!'; 190 | var Lambda = CfnLambda({ 191 | Create: function(Params, reply) { 192 | reply(expectedReason); 193 | } 194 | }); 195 | 196 | Server.on(function() { 197 | Lambda(CfnRequest, ContextStub); 198 | }, function(cfnResponse) { 199 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 200 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 201 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 202 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 203 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 204 | assert(expectedReason === cfnResponse.body.Reason, 'Bad error Reason'); 205 | done(); 206 | }); 207 | 208 | }); 209 | }); 210 | -------------------------------------------------------------------------------- /test/expander.js: -------------------------------------------------------------------------------- 1 | 2 | var path = require('path'); 3 | var assert = require('assert'); 4 | 5 | var CfnLambda = require(path.resolve(__dirname, '..', 'index')); 6 | var DefaultExpander = CfnLambda.DefaultExpander; 7 | var JSONDeepEquals = CfnLambda.JSONDeepEquals; 8 | 9 | function toBase64(json) { 10 | return new Buffer(JSON.stringify(json)).toString('base64'); 11 | } 12 | 13 | function clone(json) { 14 | return JSON.parse(JSON.stringify(json)); 15 | } 16 | 17 | function assertJSONEquality(fragment, inJSON, outJSON, expectedJSON) { 18 | assert(JSONDeepEquals(outJSON, expectedJSON), [ 19 | 'Debug: ', 20 | 'FRAG: ' + JSON.stringify(fragment, null, 2), 21 | 'IN: ' + JSON.stringify(inJSON, null, 2), 22 | 'OUT: ' + JSON.stringify(outJSON, null, 2), 23 | 'EXPECTS: ' + JSON.stringify(expectedJSON, null, 2) 24 | ].join('\n')); 25 | } 26 | 27 | describe('DefaultExpander', function() { 28 | it('should do nothing with no __default__', function(done) { 29 | var fragment = null; 30 | var inJSON = { 31 | foo: 'bar', 32 | baz: 'qux', 33 | deeper: { 34 | properties: 'exist' 35 | }, 36 | supports: { 37 | arrays: ['in', 'deep', 'properties'] 38 | } 39 | }; 40 | var expectedJSON = inJSON; 41 | var outJSON = DefaultExpander(inJSON); 42 | assertJSONEquality(fragment, inJSON, outJSON, expectedJSON); 43 | done(); 44 | }); 45 | it('should expand __default__ on root key', function(done) { 46 | var fragment = { 47 | Foo: 'Bar' 48 | }; 49 | var inJSON = { 50 | __default__: toBase64(fragment), 51 | Baz: 'Qux' 52 | }; 53 | var expectedJSON = { 54 | Foo: 'Bar', 55 | Baz: 'Qux' 56 | }; 57 | var outJSON = DefaultExpander(inJSON); 58 | assertJSONEquality(fragment, inJSON, outJSON, expectedJSON); 59 | done(); 60 | }); 61 | it('should expand __default__ on and overwrite defined keys', function(done) { 62 | var fragment = { 63 | Foo: 'Bar', 64 | Overlap: 'Overwritten' 65 | }; 66 | var inJSON = { 67 | __default__: toBase64(fragment), 68 | Baz: 'Qux', 69 | Overlap: 'NewValue' 70 | }; 71 | var expectedJSON = { 72 | Foo: 'Bar', 73 | Baz: 'Qux', 74 | Overlap: 'NewValue' 75 | }; 76 | var outJSON = DefaultExpander(inJSON); 77 | assertJSONEquality(fragment, inJSON, outJSON, expectedJSON); 78 | done(); 79 | }); 80 | it('should expand __default__ on non-root keys', function(done) { 81 | var fragment = { 82 | Foo: 'Bar' 83 | }; 84 | var inJSON = { 85 | Baz: 'Qux', 86 | DeepExpansion: { 87 | __default__: toBase64(fragment) 88 | } 89 | }; 90 | var expectedJSON = { 91 | Baz: 'Qux', 92 | DeepExpansion: { 93 | Foo: 'Bar' 94 | } 95 | }; 96 | var outJSON = DefaultExpander(inJSON); 97 | assertJSONEquality(fragment, inJSON, outJSON, expectedJSON); 98 | done(); 99 | }); 100 | it('should expand __default__ and overwrite on non-root keys', function(done) { 101 | var fragment = { 102 | Foo: 'Bar', 103 | Overlap: 'Overwritten' 104 | }; 105 | var inJSON = { 106 | Baz: 'Qux', 107 | DeepExpansion: { 108 | __default__: toBase64(fragment), 109 | Overlap: 'NewValue' 110 | } 111 | }; 112 | var expectedJSON = { 113 | Baz: 'Qux', 114 | DeepExpansion: { 115 | Foo: 'Bar', 116 | Overlap: 'NewValue' 117 | } 118 | }; 119 | var outJSON = DefaultExpander(inJSON); 120 | assertJSONEquality(fragment, inJSON, outJSON, expectedJSON); 121 | done(); 122 | }); 123 | it('should expand __default__ on Array values', function(done) { 124 | var fragment = { 125 | Foo: 'Bar' 126 | }; 127 | var inJSON = { 128 | Baz: 'Qux', 129 | DeepExpansion: { 130 | Arr: [ 131 | { 132 | Existing: 'Element' 133 | }, 134 | { 135 | __default__: toBase64(fragment) 136 | } 137 | ] 138 | } 139 | }; 140 | var expectedJSON = { 141 | Baz: 'Qux', 142 | DeepExpansion: { 143 | Arr: [ 144 | { 145 | Existing: 'Element' 146 | }, 147 | { 148 | Foo: 'Bar' 149 | } 150 | ] 151 | } 152 | }; 153 | var outJSON = DefaultExpander(inJSON); 154 | assertJSONEquality(fragment, inJSON, outJSON, expectedJSON); 155 | done(); 156 | }); 157 | it('should expand __default__ and overwrite on Array values', function(done) { 158 | var fragment = { 159 | Foo: 'Bar', 160 | Overlap: 'Overwritten' 161 | }; 162 | var inJSON = { 163 | Baz: 'Qux', 164 | DeepExpansion: { 165 | Arr: [ 166 | { 167 | Existing: 'Element' 168 | }, 169 | { 170 | __default__: toBase64(fragment), 171 | Overlap: 'NewValue' 172 | } 173 | ] 174 | } 175 | }; 176 | var expectedJSON = { 177 | Baz: 'Qux', 178 | DeepExpansion: { 179 | Arr: [ 180 | { 181 | Existing: 'Element' 182 | }, 183 | { 184 | Foo: 'Bar', 185 | Overlap: 'NewValue' 186 | } 187 | ] 188 | } 189 | }; 190 | var outJSON = DefaultExpander(inJSON); 191 | assertJSONEquality(fragment, inJSON, outJSON, expectedJSON); 192 | done(); 193 | }); 194 | it('should work with nesting', function(done) { 195 | var subfragment = { 196 | deepest: 'variable' 197 | }; 198 | var fragment = { 199 | __default__: toBase64(subfragment), 200 | Foo: 'Bar', 201 | Overlap: 'Overwritten', 202 | }; 203 | var inJSON = { 204 | Baz: 'Qux', 205 | DeepExpansion: { 206 | Arr: [ 207 | { 208 | Existing: 'Element' 209 | }, 210 | { 211 | __default__: toBase64(fragment), 212 | Overlap: 'NewValue' 213 | } 214 | ] 215 | } 216 | }; 217 | var expectedJSON = { 218 | Baz: 'Qux', 219 | DeepExpansion: { 220 | Arr: [ 221 | { 222 | Existing: 'Element' 223 | }, 224 | { 225 | Foo: 'Bar', 226 | Overlap: 'NewValue', 227 | deepest: 'variable' 228 | } 229 | ] 230 | } 231 | }; 232 | var outJSON = DefaultExpander(inJSON); 233 | assertJSONEquality(fragment, inJSON, outJSON, expectedJSON); 234 | done(); 235 | }); 236 | it('should work with Array __default__', function(done) { 237 | var fragment = [ 238 | 0, 239 | 1 240 | ]; 241 | var inJSON = { 242 | Baz: 'Qux', 243 | DeepExpansion: { 244 | Arr: [ 245 | { 246 | Existing: 'Element' 247 | }, 248 | { 249 | __default__: toBase64(fragment) 250 | } 251 | ] 252 | } 253 | }; 254 | var expectedJSON = { 255 | Baz: 'Qux', 256 | DeepExpansion: { 257 | Arr: [ 258 | { 259 | Existing: 'Element' 260 | }, 261 | [ 262 | 0, 263 | 1 264 | ] 265 | ] 266 | } 267 | }; 268 | var outJSON = DefaultExpander(inJSON); 269 | assertJSONEquality(fragment, inJSON, outJSON, expectedJSON); 270 | done(); 271 | }); 272 | it('should work with Array __default__ being overwritten', function(done) { 273 | var fragment = [ 274 | 0, 275 | 1 276 | ]; 277 | var inJSON = { 278 | Baz: 'Qux', 279 | DeepExpansion: { 280 | Arr: [ 281 | { 282 | Existing: 'Element' 283 | }, 284 | { 285 | __default__: toBase64(fragment), 286 | NoLonger: 'Array' 287 | } 288 | ] 289 | } 290 | }; 291 | var expectedJSON = { 292 | Baz: 'Qux', 293 | DeepExpansion: { 294 | Arr: [ 295 | { 296 | Existing: 'Element' 297 | }, 298 | { 299 | NoLonger: 'Array' 300 | } 301 | ] 302 | } 303 | }; 304 | var outJSON = DefaultExpander(inJSON); 305 | assertJSONEquality(fragment, inJSON, outJSON, expectedJSON); 306 | done(); 307 | }); 308 | it('should work with primitive __default__', function(done) { 309 | var fragment = null; 310 | var inJSON = { 311 | Baz: 'Qux', 312 | DeepExpansion: { 313 | Arr: [ 314 | { 315 | Existing: 'Element' 316 | }, 317 | { 318 | __default__: toBase64(fragment), 319 | } 320 | ] 321 | } 322 | }; 323 | var expectedJSON = { 324 | Baz: 'Qux', 325 | DeepExpansion: { 326 | Arr: [ 327 | { 328 | Existing: 'Element' 329 | }, 330 | null 331 | ] 332 | } 333 | }; 334 | var outJSON = DefaultExpander(inJSON); 335 | assertJSONEquality(fragment, inJSON, outJSON, expectedJSON); 336 | done(); 337 | }); 338 | it('should work with primitive __default__ being overwritten', function(done) { 339 | var fragment = null; 340 | var inJSON = { 341 | Baz: 'Qux', 342 | DeepExpansion: { 343 | Arr: [ 344 | { 345 | Existing: 'Element' 346 | }, 347 | { 348 | __default__: toBase64(fragment), 349 | NoLonger: 'nullvalue' 350 | } 351 | ] 352 | } 353 | }; 354 | var expectedJSON = { 355 | Baz: 'Qux', 356 | DeepExpansion: { 357 | Arr: [ 358 | { 359 | Existing: 'Element' 360 | }, 361 | { 362 | NoLonger: 'nullvalue' 363 | } 364 | ] 365 | } 366 | }; 367 | var outJSON = DefaultExpander(inJSON); 368 | assertJSONEquality(fragment, inJSON, outJSON, expectedJSON); 369 | done(); 370 | }); 371 | 372 | }); 373 | -------------------------------------------------------------------------------- /test/validation.js: -------------------------------------------------------------------------------- 1 | 2 | var path = require('path'); 3 | var assert = require('assert'); 4 | var _ = require('underscore') 5 | 6 | var ValidationCheck = require(path.resolve(__dirname, '..', 'index')).ValidationCheck; 7 | 8 | describe('Validation', function() { 9 | 10 | describe('Trivial Passing', function() { 11 | it('should pass when no validations are passed', function(done) { 12 | 13 | var invalidation = ValidationCheck({}, {}); 14 | 15 | assert(!invalidation); 16 | done(); 17 | 18 | }); 19 | 20 | it('should pass on a trivial Validate function', function(done) { 21 | 22 | function trivialFunction() {} 23 | var invalidation = ValidationCheck({}, { 24 | Validate: trivialFunction 25 | }); 26 | 27 | assert(!invalidation); 28 | done(); 29 | 30 | }); 31 | 32 | it('should pass on a trivial JSONSchema', function(done) { 33 | 34 | var trivialSchema = { 35 | type: 'object', 36 | properties: {} 37 | }; 38 | var invalidation = ValidationCheck({}, { 39 | Schema: trivialSchema 40 | }); 41 | 42 | assert(!invalidation); 43 | done(); 44 | 45 | }); 46 | }); 47 | 48 | describe('Bad Validation Definitions', function() { 49 | it('should break on non-Function Validate property', function(done) { 50 | 51 | var badValidateProperty = 'not an Object or Function...'; 52 | 53 | var badValidateErrorMessage = 'FATAL: Any Lambda Validate should be a function.'; 54 | 55 | var invalidation = ValidationCheck('anything', { 56 | Validate: badValidateProperty 57 | }); 58 | 59 | assert(invalidation === badValidateErrorMessage); 60 | done(); 61 | }); 62 | 63 | it('should break on non-plain-Object Schema property', function(done) { 64 | 65 | var nonObject = 'A non-object...'; 66 | var badSchemaPropertyErrorMessage = 'FATAL: Any Lambda Schema ' + 67 | 'should be a plain Object.'; 68 | 69 | var invalidation = ValidationCheck('anything', { 70 | Schema: nonObject 71 | }); 72 | 73 | assert(invalidation === badSchemaPropertyErrorMessage); 74 | done(); 75 | }); 76 | 77 | it('should break on malformed JSONSchema Schema plain Object property', function(done) { 78 | 79 | var badSchema = { 80 | type: 'terrible schema' 81 | }; 82 | var badJSONSchemaErrorMessage = 'The custom resource\'s schema was an ' + 83 | 'object, but was not valid JSONSchema v4.'; 84 | 85 | var invalidation = ValidationCheck('anything', { 86 | Schema: badSchema 87 | }); 88 | 89 | assert(invalidation === badJSONSchemaErrorMessage); 90 | done(); 91 | }); 92 | 93 | it('should break on non-Array SchemaPath property', function(done) { 94 | 95 | var nonArray = 'definitely not an array...'; 96 | var badSchemaPathErrorMessage = 'FATAL: Any Lambda SchemaPath ' + 97 | 'should be an Array of String.'; 98 | 99 | var invalidation = ValidationCheck('anything', { 100 | SchemaPath: nonArray 101 | }); 102 | 103 | assert(invalidation === badSchemaPathErrorMessage); 104 | done(); 105 | }); 106 | 107 | it('should break on non-String SchemaPath property Array elements', function(done) { 108 | 109 | var arrayWithNonString = ['ok', 'fine', {oopsie: 'daisy'}]; 110 | var badSchemaPathErrorMessage = 'FATAL: Any Lambda SchemaPath ' + 111 | 'should be an Array of String.'; 112 | 113 | var invalidation = ValidationCheck('anything', { 114 | SchemaPath: arrayWithNonString 115 | }); 116 | 117 | assert(invalidation === badSchemaPathErrorMessage); 118 | done(); 119 | }); 120 | 121 | it('should break on invalid JSON file for SchemaPath property', function(done) { 122 | 123 | var badPath = ['tmp', 'foobar.notjson']; 124 | var badJSONFileErrorMessage = 'FATAL: No JSON was found at SchemaPath'; 125 | 126 | var invalidation = ValidationCheck('anything', { 127 | SchemaPath: badPath 128 | }); 129 | 130 | assert(invalidation === badJSONFileErrorMessage); 131 | done(); 132 | }); 133 | 134 | }); 135 | 136 | describe('Function Validation', function() { 137 | 138 | var myCustomValidationError = 'Property "numbers" should be Array of Number adding to 10.'; 139 | function myCustomValidator(params) { 140 | return (params && 141 | Array.isArray(params.numbers) && 142 | params.numbers.every(isFiniteNumber) && 143 | params.numbers.reduce(plus, 0) === 10) 144 | ? false 145 | : myCustomValidationError; 146 | function isFiniteNumber(n) { 147 | return 'number' === typeof n && isFinite(n); 148 | } 149 | function plus(a, b) { 150 | return a + b; 151 | } 152 | } 153 | 154 | it('should catch bad properties', function(done) { 155 | 156 | var params = { 157 | numbers: 'taco!' 158 | }; 159 | 160 | var invalidation = ValidationCheck(params, { 161 | Validate: myCustomValidator 162 | }); 163 | 164 | assert(invalidation === myCustomValidationError); 165 | done(); 166 | 167 | }); 168 | 169 | it('should catch missing properties', function(done) { 170 | 171 | var params = undefined; 172 | 173 | var invalidation = ValidationCheck(params, { 174 | Validate: myCustomValidator 175 | }); 176 | 177 | assert(invalidation === myCustomValidationError); 178 | done(); 179 | 180 | }); 181 | 182 | it('should pass good properties', function(done) { 183 | 184 | var params = { 185 | numbers: [1, 2, 3, 4] 186 | }; 187 | 188 | var invalidation = ValidationCheck(params, { 189 | Validate: myCustomValidator 190 | }); 191 | 192 | assert(!invalidation); 193 | done(); 194 | 195 | }); 196 | }); 197 | 198 | describe('Schema Object Validation', function() { 199 | 200 | var goodSchema = { 201 | type: 'object', 202 | required: [ 203 | 'name' 204 | ], 205 | properties: { 206 | name: { 207 | type: 'string' 208 | }, 209 | cloneFrom: { 210 | type: 'string' 211 | }, 212 | description: { 213 | type: 'string' 214 | } 215 | } 216 | }; 217 | 218 | it('should validate a good schema', function(done) { 219 | 220 | var goodParams = { 221 | name: 'myapi', 222 | description: 'Foobarbazqux' 223 | }; 224 | 225 | var invalidation = ValidationCheck(goodParams, { 226 | Schema: goodSchema 227 | }); 228 | 229 | assert(!invalidation); 230 | done(); 231 | 232 | }); 233 | 234 | it('should break on missing required property', function(done) { 235 | 236 | var missingName = { 237 | description: 'oops this should explode' 238 | }; 239 | var missingNameError = JSON.stringify([[["property ==>","instance"],["message ==>","requires property \"name\""],["schema ==>",{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"cloneFrom":{"type":"string"},"description":{"type":"string"}}}],["instance ==>",{"description":"oops this should explode"}],["name ==>","required"],["argument ==>","name"],["stack ==>","instance requires property \"name\""]]]) 240 | 241 | var invalidation = ValidationCheck(missingName, { 242 | Schema: goodSchema 243 | }); 244 | 245 | assert(_.isEqual(invalidation, missingNameError)); 246 | done(); 247 | 248 | }); 249 | 250 | it('should break on bad type', function(done) { 251 | 252 | var badCloneFrom = { 253 | name: 'Andrew Templeton', 254 | cloneFrom: ['not', 'a', 'string', 'oops!'] 255 | }; 256 | var badCloneFromError = JSON.stringify([[["property ==>","instance.cloneFrom"],["message ==>","is not of a type(s) string"],["schema ==>",{"type":"string"}],["instance ==>",["not","a","string","oops!"]],["name ==>","type"],["argument ==>",["string"]],["stack ==>","instance.cloneFrom is not of a type(s) string"]]]); 257 | 258 | var invalidation = ValidationCheck(badCloneFrom, { 259 | Schema: goodSchema 260 | }); 261 | 262 | assert(invalidation === badCloneFromError); 263 | done(); 264 | 265 | }); 266 | }); 267 | 268 | describe('SchemaPath Object Validation', function() { 269 | 270 | var goodSchemaPath = [ 271 | __dirname, 272 | '..', 273 | 'test-helpers', 274 | 'test.schema.json' 275 | ]; 276 | 277 | it('should validate a good schema', function(done) { 278 | 279 | var goodParams = { 280 | name: 'myapi', 281 | description: 'Foobarbazqux' 282 | }; 283 | 284 | var invalidation = ValidationCheck(goodParams, { 285 | SchemaPath: goodSchemaPath 286 | }); 287 | 288 | assert(!invalidation); 289 | done(); 290 | 291 | }); 292 | 293 | it('should break on missing required property', function(done) { 294 | 295 | var missingName = { 296 | description: 'oops this should explode' 297 | }; 298 | var missingNameError = JSON.stringify([[["property ==>","instance"],["message ==>","requires property \"name\""],["schema ==>",{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"cloneFrom":{"type":"string"},"description":{"type":"string"}}}],["instance ==>",{"description":"oops this should explode"}],["name ==>","required"],["argument ==>","name"],["stack ==>","instance requires property \"name\""]]]); 299 | 300 | var invalidation = ValidationCheck(missingName, { 301 | SchemaPath: goodSchemaPath 302 | }); 303 | 304 | assert(invalidation === missingNameError); 305 | done(); 306 | 307 | }); 308 | 309 | it('should break on bad type', function(done) { 310 | 311 | var badCloneFrom = { 312 | name: 'Andrew Templeton', 313 | cloneFrom: ['not', 'a', 'string', 'oops!'] 314 | }; 315 | var badCloneFromError = JSON.stringify([[["property ==>","instance.cloneFrom"],["message ==>","is not of a type(s) string"],["schema ==>",{"type":"string"}],["instance ==>",["not","a","string","oops!"]],["name ==>","type"],["argument ==>",["string"]],["stack ==>","instance.cloneFrom is not of a type(s) string"]]]); 316 | 317 | var invalidation = ValidationCheck(badCloneFrom, { 318 | SchemaPath: goodSchemaPath 319 | }); 320 | 321 | assert(invalidation === badCloneFromError); 322 | 323 | done(); 324 | 325 | }); 326 | }); 327 | 328 | }); 329 | -------------------------------------------------------------------------------- /test/async.js: -------------------------------------------------------------------------------- 1 | 2 | var path = require('path'); 3 | var assert = require('assert'); 4 | 5 | var Server = require(path.resolve(__dirname, '..', 'test-helpers', 'https', 'server')); 6 | var ContextStub = require(path.resolve(__dirname, '..', 'test-helpers', 'context')); 7 | var CfnLambda = require(path.resolve(__dirname, '..', 'index')); 8 | 9 | describe('Async support', function() { 10 | var expectedUrl = '/foo/bar/taco'; 11 | var expectedStackId = 'fakeStackId'; 12 | var expectedRequestId = 'fakeRequestId'; 13 | var expectedLogicalResourceId = 'MyTestResource'; 14 | function HollowCreateRequest() { 15 | return { 16 | RequestType: 'Create', 17 | ResponseURL: 'https://localhost:13002' + expectedUrl, 18 | StackId: expectedStackId, 19 | RequestId: expectedRequestId, 20 | ResourceType: 'Custom::TestResource', 21 | LogicalResourceId: expectedLogicalResourceId, 22 | ResourceProperties: { 23 | Foo: 'Bar' 24 | } 25 | }; 26 | } 27 | function HollowUpdateRequest() { 28 | return { 29 | RequestType: 'Update', 30 | ResponseURL: 'https://localhost:13002' + expectedUrl, 31 | StackId: expectedStackId, 32 | RequestId: expectedRequestId, 33 | ResourceType: 'Custom::TestResource', 34 | LogicalResourceId: expectedLogicalResourceId, 35 | PhysicalResourceId: 'someFakeId', 36 | ResourceProperties: { 37 | Foo: 'Bar' 38 | }, 39 | OldResourceProperties: { 40 | Foo: 'Boo' 41 | } 42 | }; 43 | } 44 | function HollowDeleteRequest() { 45 | return { 46 | RequestType: 'Delete', 47 | ResponseURL: 'https://localhost:13002' + expectedUrl, 48 | StackId: expectedStackId, 49 | RequestId: expectedRequestId, 50 | ResourceType: 'Custom::TestResource', 51 | LogicalResourceId: expectedLogicalResourceId, 52 | PhysicalResourceId: 'someFakeId', 53 | ResourceProperties: { 54 | Foo: 'Bar' 55 | } 56 | }; 57 | } 58 | function wait() { 59 | return new Promise(function(resolve, reject) { 60 | setTimeout(function() { 61 | resolve(); 62 | }, 50); 63 | }); 64 | } 65 | it('Should send error.messge to CloudFormation', function(done) { 66 | var CfnRequest = HollowCreateRequest(); 67 | var errorMessage = 'Worked, it did not'; 68 | var Lambda = CfnLambda({ 69 | AsyncCreate: async function() { 70 | throw new Error(errorMessage); 71 | } 72 | }); 73 | Server.on(function() { 74 | Lambda(CfnRequest, ContextStub); 75 | }, function(cfnResponse) { 76 | assert(cfnResponse.body.Status === 'FAILED', 'Failed but replied with SUCCESS status'); 77 | assert(cfnResponse.body.Reason === errorMessage, 'Error message doesnt match'); 78 | // test would fail after 2s timeout if reply() callback is never called 79 | done(); 80 | }); 81 | }); 82 | it('Should reply to server with response values when using AsyncCreate', function(done) { 83 | var CfnRequest = HollowCreateRequest(); 84 | var response = { 85 | PhysicalResourceId: 'yopadope', 86 | FnGetAttrsDataObj: { 87 | MyObj: 'dopeayope' 88 | } 89 | }; 90 | var Lambda = CfnLambda({ 91 | AsyncCreate: async function(Params) { 92 | assert(Params.Foo === 'Bar', 'ResourceProperty Foo doesnt match'); 93 | await wait(); 94 | return response; 95 | } 96 | }); 97 | Server.on(function() { 98 | Lambda(CfnRequest, ContextStub); 99 | }, function(cfnResponse) { 100 | assert(cfnResponse.body.Status === 'SUCCESS', 'Did not reply with SUCCESS status'); 101 | assert(cfnResponse.body.PhysicalResourceId === response.PhysicalResourceId, 'PhysicalResourceId doesnt match'); 102 | assert(cfnResponse.body.Data.MyObj === response.FnGetAttrsDataObj.MyObj, 'FnGetAttrsDataObj doesnt match'); 103 | // test would fail after 2s timeout if reply() callback is never called 104 | done(); 105 | }); 106 | }); 107 | it('Should reply to server with response values when using AsyncDelete', function(done) { 108 | var CfnRequest = HollowDeleteRequest(); 109 | var response = { 110 | PhysicalResourceId: 'yopadope', 111 | FnGetAttrsDataObj: { 112 | MyObj: 'dopeayope' 113 | } 114 | }; 115 | var Lambda = CfnLambda({ 116 | AsyncDelete: async function(PhysicalId, Params) { 117 | assert(PhysicalId === 'someFakeId', 'PhysicalId doesnt match'); 118 | assert(Params.Foo === 'Bar', 'ResourceProperty Foo doesnt match'); 119 | await wait(); 120 | return response; 121 | } 122 | }); 123 | Server.on(function() { 124 | Lambda(CfnRequest, ContextStub); 125 | }, function(cfnResponse) { 126 | assert(cfnResponse.body.Status === 'SUCCESS', 'Did not reply with SUCCESS status'); 127 | assert(cfnResponse.body.PhysicalResourceId === response.PhysicalResourceId, 'PhysicalResourceId doesnt match'); 128 | assert(cfnResponse.body.Data.MyObj === response.FnGetAttrsDataObj.MyObj, 'FnGetAttrsDataObj doesnt match'); 129 | // test would fail after 2s timeout if reply() callback is never called 130 | done(); 131 | }); 132 | }); 133 | it('Should reply to server with response values when using AsyncUpdate', function(done) { 134 | var CfnRequest = HollowUpdateRequest(); 135 | var response = { 136 | PhysicalResourceId: 'yopadope', 137 | FnGetAttrsDataObj: { 138 | MyObj: 'dopeayope' 139 | } 140 | }; 141 | var Lambda = CfnLambda({ 142 | AsyncUpdate: async function(PhysicalId, Params, OldParams) { 143 | assert(PhysicalId === 'someFakeId', 'PhysicalId doesnt match'); 144 | assert(Params.Foo === 'Bar', 'ResourceProperty Foo doesnt match'); 145 | assert(OldParams.Foo === 'Boo', 'OldResourceProperty Foo doesnt match'); 146 | await wait(); 147 | return response; 148 | } 149 | }); 150 | Server.on(function() { 151 | Lambda(CfnRequest, ContextStub); 152 | }, function(cfnResponse) { 153 | assert(cfnResponse.body.Status === 'SUCCESS', 'Did not reply with SUCCESS status'); 154 | assert(cfnResponse.body.PhysicalResourceId === response.PhysicalResourceId, 'PhysicalResourceId doesnt match'); 155 | assert(cfnResponse.body.Data.MyObj === response.FnGetAttrsDataObj.MyObj, 'FnGetAttrsDataObj doesnt match'); 156 | // test would fail after 2s timeout if reply() callback is never called 157 | done(); 158 | }); 159 | }); 160 | it('Should reply to server with response values when using AsyncNoUpdate', function(done) { 161 | var CfnRequest = HollowUpdateRequest(); 162 | CfnRequest.ResourceProperties = { 163 | Foo: 'Boo' // Same value as OldResourceProperties 164 | }; 165 | var response = { 166 | PhysicalResourceId: 'yopadope', 167 | FnGetAttrsDataObj: { 168 | MyObj: 'dopeayope' 169 | } 170 | }; 171 | var Lambda = CfnLambda({ 172 | AsyncNoUpdate: async function(PhysicalId, Params) { 173 | assert(PhysicalId === 'someFakeId', 'PhysicalId doesnt match'); 174 | assert(Params.Foo === 'Boo', 'ResourceProperty Foo doesnt match'); 175 | await wait(); 176 | return response; 177 | } 178 | }); 179 | Server.on(function() { 180 | Lambda(CfnRequest, ContextStub); 181 | }, function(cfnResponse) { 182 | assert(cfnResponse.body.Status === 'SUCCESS', 'Did not reply with SUCCESS status'); 183 | assert(cfnResponse.body.PhysicalResourceId === response.PhysicalResourceId, 'PhysicalResourceId doesnt match'); 184 | assert(cfnResponse.body.Data.MyObj === response.FnGetAttrsDataObj.MyObj, 'FnGetAttrsDataObj doesnt match'); 185 | // test would fail after 2s timeout if reply() callback is never called 186 | done(); 187 | }); 188 | }); 189 | it('Should prioritize regular Create over AsyncCreate', function(done) { 190 | var CfnRequest = HollowCreateRequest(); 191 | var asyncCalled = false; 192 | var regularCalled = false; 193 | var Lambda = CfnLambda({ 194 | AsyncCreate: function(Params) { 195 | asyncCalled = true; 196 | }, 197 | Create: function(Params, reply) { 198 | regularCalled = true; 199 | reply(); 200 | } 201 | }); 202 | Server.on(function() { 203 | Lambda(CfnRequest, ContextStub); 204 | }, function(cfnResponse) { 205 | assert(asyncCalled === false, 'Should not call async version'); 206 | assert(regularCalled === true, 'Should call regular version'); 207 | done(); 208 | }); 209 | }); 210 | it('Should prioritize regular Delete over AsyncDelete', function(done) { 211 | var CfnRequest = HollowDeleteRequest(); 212 | var asyncCalled = false; 213 | var regularCalled = false; 214 | var Lambda = CfnLambda({ 215 | AsyncDelete: function(PhysicalId, Params) { 216 | asyncCalled = true; 217 | }, 218 | Delete: function(PhysicalId, Params, reply) { 219 | regularCalled = true; 220 | reply(); 221 | } 222 | }); 223 | Server.on(function() { 224 | Lambda(CfnRequest, ContextStub); 225 | }, function(cfnResponse) { 226 | assert(asyncCalled === false, 'Should not call async version'); 227 | assert(regularCalled === true, 'Should call regular version'); 228 | done(); 229 | }); 230 | }); 231 | it('Should prioritize regular Update over AsyncUpdate', function(done) { 232 | var CfnRequest = HollowUpdateRequest(); 233 | var asyncCalled = false; 234 | var regularCalled = false; 235 | var Lambda = CfnLambda({ 236 | AsyncUpdate: function(PhysicalId, Params, OldParams) { 237 | asyncCalled = true; 238 | }, 239 | Update: function(PhysicalId, Params, OldParams, reply) { 240 | regularCalled = true; 241 | reply(); 242 | } 243 | }); 244 | Server.on(function() { 245 | Lambda(CfnRequest, ContextStub); 246 | }, function(cfnResponse) { 247 | assert(asyncCalled === false, 'Should not call async version'); 248 | assert(regularCalled === true, 'Should call regular version'); 249 | done(); 250 | }); 251 | }); 252 | it('Should prioritize regular NoUpdate over AsyncNoUpdate', function(done) { 253 | var CfnRequest = HollowUpdateRequest(); 254 | CfnRequest.ResourceProperties = { 255 | Foo: 'Boo' // Same value as OldResourceProperties 256 | }; 257 | var asyncCalled = false; 258 | var regularCalled = false; 259 | var Lambda = CfnLambda({ 260 | AsyncNoUpdate: function(PhysicalId, Params) { 261 | asyncCalled = true; 262 | }, 263 | NoUpdate: function(PhysicalId, Params, reply) { 264 | regularCalled = true; 265 | reply(); 266 | } 267 | }); 268 | Server.on(function() { 269 | Lambda(CfnRequest, ContextStub); 270 | }, function(cfnResponse) { 271 | assert(asyncCalled === false, 'Should not call async version'); 272 | assert(regularCalled === true, 'Should call regular version'); 273 | done(); 274 | }); 275 | }); 276 | }); 277 | -------------------------------------------------------------------------------- /test/update.js: -------------------------------------------------------------------------------- 1 | 2 | var path = require('path'); 3 | var assert = require('assert'); 4 | 5 | var Server = require(path.resolve(__dirname, '..', 'test-helpers', 'https', 'server')); 6 | var ContextStub = require(path.resolve(__dirname, '..', 'test-helpers', 'context')); 7 | 8 | var CfnLambda = require(path.resolve(__dirname, '..', 'index')); 9 | 10 | 11 | describe('Update', function() { 12 | var expectedUrl = '/foo/bar/taco'; 13 | var expectedStackId = 'fakeStackId'; 14 | var expectedRequestId = 'fakeRequestId'; 15 | var expectedLogicalResourceId = 'MyTestResource'; 16 | function HollowRequest() { 17 | return { 18 | RequestType: 'Update', 19 | ResponseURL: 'https://localhost:13002' + expectedUrl, 20 | StackId: expectedStackId, 21 | RequestId: expectedRequestId, 22 | ResourceType: 'Custom::TestResource', 23 | LogicalResourceId: expectedLogicalResourceId, 24 | PhysicalResourceId: 'someFakeId', 25 | OldResourceProperties: { 26 | Foo: ['array', 'of', 'string'] 27 | } 28 | }; 29 | } 30 | it('Should work with unchanged PhysicalResourceId', function(done) { 31 | var CfnRequest = HollowRequest(); 32 | var expectedStatus = 'SUCCESS'; 33 | var Lambda = CfnLambda({ 34 | Update: function(PhysicalId, Params, OldParams, reply) { 35 | reply(); 36 | } 37 | }); 38 | 39 | Server.on(function() { 40 | Lambda(CfnRequest, ContextStub); 41 | }, function(cfnResponse) { 42 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 43 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 44 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 45 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 46 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 47 | console.log(cfnResponse.body.PhysicalResourceId) 48 | assert(CfnRequest.PhysicalResourceId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 49 | done(); 50 | }); 51 | 52 | }); 53 | 54 | it('Should work with a provided PhysicalResourceId', function(done) { 55 | var CfnRequest = HollowRequest(); 56 | var expectedStatus = 'SUCCESS'; 57 | var expectedPhysicalId = 'someValueProvided'; 58 | var Lambda = CfnLambda({ 59 | Update: function(PhysicalId, Params, OldParams, reply) { 60 | reply(null, expectedPhysicalId); 61 | } 62 | }); 63 | 64 | Server.on(function() { 65 | Lambda(CfnRequest, ContextStub); 66 | }, function(cfnResponse) { 67 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 68 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 69 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 70 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 71 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 72 | assert(expectedPhysicalId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 73 | done(); 74 | }); 75 | 76 | }); 77 | 78 | it('Should work with a provided Data set', function(done) { 79 | var CfnRequest = HollowRequest(); 80 | var expectedStatus = 'SUCCESS'; 81 | var expectedPhysicalId = 'someValueProvided'; 82 | var expectedData = { 83 | Foo: 'bar' 84 | }; 85 | var Lambda = CfnLambda({ 86 | Update: function(PhysicalId, Params, OldParams, reply) { 87 | reply(null, expectedPhysicalId, expectedData); 88 | } 89 | }); 90 | 91 | Server.on(function() { 92 | Lambda(CfnRequest, ContextStub); 93 | }, function(cfnResponse) { 94 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 95 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 96 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 97 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 98 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 99 | assert(expectedPhysicalId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 100 | assert(JSON.stringify(expectedData) === 101 | JSON.stringify(cfnResponse.body.Data), 'Bad Data payload'); 102 | done(); 103 | }); 104 | 105 | }); 106 | 107 | it('Should pass with good ResourceProperties', function(done) { 108 | var CfnRequest = HollowRequest(); 109 | var expectedStatus = 'SUCCESS'; 110 | CfnRequest.ResourceProperties = { 111 | Foo: ['array', 'of', 'string', 'values'] 112 | }; 113 | var expectedPhysicalId = 'someValueProvided'; 114 | var expectedData = { 115 | foo: 'bar' 116 | }; 117 | function isString(thing) { 118 | return 'string' === typeof thing; 119 | } 120 | var Lambda = CfnLambda({ 121 | Update: function(PhysicalId, Params, OldParams, reply) { 122 | reply(null, expectedPhysicalId, expectedData); 123 | }, 124 | Validate: function(ResourceProperties) { 125 | if (!ResourceProperties || 126 | !Array.isArray(ResourceProperties.Foo) || 127 | !ResourceProperties.Foo.every(isString)) { 128 | console.log('FAILED VALIDATION: %j', ResourceProperties); 129 | return 'Propery Foo must be an Array of String'; 130 | } 131 | } 132 | }); 133 | 134 | Server.on(function() { 135 | Lambda(CfnRequest, ContextStub); 136 | }, function(cfnResponse) { 137 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 138 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 139 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 140 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 141 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 142 | assert(expectedPhysicalId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 143 | assert(JSON.stringify(expectedData) === 144 | JSON.stringify(cfnResponse.body.Data), 'Bad Data payload'); 145 | done(); 146 | }); 147 | 148 | }); 149 | 150 | it('Should fail with bad ResourceProperties', function(done) { 151 | var CfnRequest = HollowRequest(); 152 | var expectedStatus = 'FAILED'; 153 | CfnRequest.ResourceProperties = { 154 | Foo: ['array', 'of', 'NOT ALL', {string: 'values'}] 155 | }; 156 | var Lambda = CfnLambda({ 157 | Update: function(PhysicalId, Params, OldParams, reply) { 158 | reply(null, expectedPhysicalId, expectedData); 159 | }, 160 | Validate: function(ResourceProperties) { 161 | if (!ResourceProperties || 162 | !Array.isArray(ResourceProperties.Foo) || 163 | !ResourceProperties.Foo.every(isString)) { 164 | console.log('FAILED VALIDATION: %j', ResourceProperties); 165 | return 'Propery Foo must be an Array of String'; 166 | } 167 | function isString(thing) { 168 | return 'string' === typeof thing; 169 | } 170 | } 171 | }); 172 | 173 | Server.on(function() { 174 | Lambda(CfnRequest, ContextStub); 175 | }, function(cfnResponse) { 176 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 177 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 178 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 179 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 180 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 181 | done(); 182 | }); 183 | 184 | }); 185 | 186 | it('Should fail with correct messaging', function(done) { 187 | var CfnRequest = HollowRequest(); 188 | var expectedStatus = 'FAILED'; 189 | var expectedReason = 'You done goofed, son!!'; 190 | var Lambda = CfnLambda({ 191 | Update: function(PhysicalId, Params, OldParams, reply) { 192 | reply(expectedReason); 193 | } 194 | }); 195 | 196 | Server.on(function() { 197 | Lambda(CfnRequest, ContextStub); 198 | }, function(cfnResponse) { 199 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 200 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 201 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 202 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 203 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 204 | assert(expectedReason === cfnResponse.body.Reason, 'Bad error Reason'); 205 | done(); 206 | }); 207 | 208 | }); 209 | 210 | it('Should bypass update with default NoUpdate', function(done) { 211 | var CfnRequest = HollowRequest(); 212 | var expectedStatus = 'SUCCESS'; 213 | var expectedReason = 'You done goofed, son!!'; 214 | var updateWasRun = false; 215 | CfnRequest.ResourceProperties = { 216 | Foo: ['array', 'of', 'string'] // Same value as OldResourceProperties 217 | }; 218 | var Lambda = CfnLambda({ 219 | Update: function(PhysicalId, Params, OldParams, reply) { 220 | updateWasRun = true; 221 | reply(); 222 | } 223 | }); 224 | 225 | Server.on(function() { 226 | Lambda(CfnRequest, ContextStub); 227 | }, function(cfnResponse) { 228 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 229 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 230 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 231 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 232 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 233 | assert(!updateWasRun, 'Update ran, should not run'); 234 | done(); 235 | }); 236 | 237 | }); 238 | 239 | it('Should run custom code with custom NoUpdate', function(done) { 240 | var CfnRequest = HollowRequest(); 241 | var expectedStatus = 'SUCCESS'; 242 | var expectedReason = 'You done goofed, son!!'; 243 | var updateWasRun = false; 244 | var expectedAttr = 'attrs'; 245 | CfnRequest.ResourceProperties = { 246 | Foo: ['array', 'of', 'string'] 247 | }; 248 | var Lambda = CfnLambda({ 249 | Update: function(PhysicalId, Params, OldParams, reply) { 250 | updateWasRun = true; 251 | reply(); 252 | }, 253 | NoUpdate: function(PhysicalId, Params, reply) { 254 | setTimeout(function() { 255 | reply(null, PhysicalId, { 256 | usable: expectedAttr 257 | }); 258 | }); 259 | } 260 | }); 261 | 262 | Server.on(function() { 263 | Lambda(CfnRequest, ContextStub); 264 | }, function(cfnResponse) { 265 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 266 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 267 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 268 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 269 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 270 | assert(CfnRequest.PhysicalResourceId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 271 | assert(expectedAttr === cfnResponse.body.Data.usable, 'Bad attrs hash'); 272 | assert(Object.keys(cfnResponse.body.Data).length === 1, 'Bad attrs hash'); 273 | assert(!updateWasRun, 'Update ran, should not run'); 274 | done(); 275 | }); 276 | 277 | }); 278 | 279 | it('Should delegate to Create when triggering Replacement', function(done) { 280 | var CfnRequest = HollowRequest(); 281 | var expectedStatus = 'SUCCESS'; 282 | var expectedReason = 'You done goofed, son!!'; 283 | var updateWasRun = false; 284 | var createWasRun = false; 285 | var expectedAttr = 'attrs'; 286 | CfnRequest.ResourceProperties = { 287 | Foo: 'CHANGED', 288 | Bar: 'unchanged' 289 | }; 290 | CfnRequest.OldResourceProperties = { 291 | Foo: 'ORIGINAL', 292 | Bar: 'unchanged' 293 | }; 294 | var Lambda = CfnLambda({ 295 | Update: function(PhysicalId, Params, OldParams, reply) { 296 | updateWasRun = true; 297 | reply(); 298 | }, 299 | Create: function(Params, reply) { 300 | createWasRun = true; 301 | reply(); 302 | }, 303 | TriggersReplacement: ['Foo'] 304 | }); 305 | 306 | Server.on(function() { 307 | Lambda(CfnRequest, ContextStub); 308 | }, function(cfnResponse) { 309 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 310 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 311 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 312 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 313 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 314 | assert(CfnRequest.PhysicalResourceId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 315 | assert(!updateWasRun, 'Update ran, should not run'); 316 | assert(createWasRun, 'Create did not run, should run'); 317 | done(); 318 | }); 319 | 320 | }); 321 | }); -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const Https = require('https') 2 | const Url = require('url') 3 | 4 | var ValidationCheck = require('./src/validationCheck') 5 | var SDKAlias = require('./src/SDKAlias') 6 | var JSONDeepEquals = require('./src/JSONDeepEquals') 7 | var DefaultExpander = require('./src/DefaultExpander') 8 | var Composite = require('./src/Composite') 9 | 10 | 11 | const PluckedEquality = (keySet, fresh, old) => JSONDeepEquals(pluck(keySet, fresh), pluck(keySet, old)) 12 | 13 | const pluck = (keySet, hash) => keySet.reduce((plucked, key) => ({ ...plucked, [key]: hash[key] }), {}) 14 | 15 | function ReplyAfterHandler(promise, reply) { 16 | promise.then(function(response) { 17 | var { PhysicalResourceId, FnGetAttrsDataObj } = response || {} 18 | reply(null, PhysicalResourceId, FnGetAttrsDataObj) 19 | }).catch(function(err) { 20 | reply(err.message || 'Unknown error') 21 | }) 22 | } 23 | 24 | const getEnvironment = ({ invokedFunctionArn }) => { 25 | const [LambdaArn, Region, AccountId, LambdaName] = invokedFunctionArn.match(/^arn:aws.*:lambda:(\w+-\w+-\d+):(\d+):function:(.*)$/) 26 | return { 27 | LambdaArn, 28 | Region, 29 | AccountId, 30 | LambdaName 31 | } 32 | } 33 | 34 | function CfnLambdaFactory(resourceDefinition) { 35 | 36 | return function CfnLambda(event, context) { 37 | 38 | // support Async handler functions: 39 | 40 | if(resourceDefinition.AsyncCreate) { 41 | if(resourceDefinition.Create) { 42 | console.log('WARNING: Both Create and AsyncCreate handlers defined. Ignoring AsyncCreate') 43 | } else { 44 | resourceDefinition.Create = function(CfnRequestParams, reply) { 45 | return ReplyAfterHandler( 46 | resourceDefinition.AsyncCreate(CfnRequestParams), reply 47 | ) 48 | } 49 | } 50 | } 51 | if(resourceDefinition.AsyncUpdate) { 52 | if(resourceDefinition.Update) { 53 | console.log('WARNING: Both Update and AsyncUpdate handlers defined. Ignoring AsyncUpdate') 54 | } else { 55 | resourceDefinition.Update = function(RequestPhysicalID, CfnRequestParams, OldCfnRequestParams, reply) { 56 | return ReplyAfterHandler( 57 | resourceDefinition.AsyncUpdate(RequestPhysicalID, CfnRequestParams, OldCfnRequestParams), reply 58 | ) 59 | } 60 | } 61 | } 62 | if(resourceDefinition.AsyncDelete) { 63 | if(resourceDefinition.Delete) { 64 | console.log('WARNING: Both Delete and AsyncDelete handlers defined. Ignoring AsyncDelete') 65 | } else { 66 | resourceDefinition.Delete = function(RequestPhysicalID, CfnRequestParams, reply) { 67 | return ReplyAfterHandler( 68 | resourceDefinition.AsyncDelete(RequestPhysicalID, CfnRequestParams), reply 69 | ) 70 | } 71 | } 72 | } 73 | if(resourceDefinition.AsyncNoUpdate) { 74 | if(resourceDefinition.NoUpdate) { 75 | console.log('WARNING: Both NoUpdate and AsyncNoUpdate handlers defined. Ignoring AsyncNoUpdate') 76 | } else { 77 | resourceDefinition.NoUpdate = function(PhysicalResourceId, CfnResourceProperties, reply) { 78 | return ReplyAfterHandler( 79 | resourceDefinition.AsyncNoUpdate(PhysicalResourceId, CfnResourceProperties), reply 80 | ) 81 | } 82 | } 83 | } 84 | 85 | if (event && event.ResourceProperties) { 86 | delete event.ResourceProperties.ServiceToken 87 | } 88 | if (event && event.OldResourceProperties) { 89 | delete event.OldResourceProperties.ServiceToken 90 | } 91 | 92 | CfnLambdaFactory.Environment = getEnvironment(context) 93 | 94 | var RequestType = event.RequestType 95 | var Params = event.ResourceProperties && 96 | DefaultExpander(event.ResourceProperties) 97 | var OldParams = event.OldResourceProperties && 98 | DefaultExpander(event.OldResourceProperties) 99 | var RequestPhysicalId = event.PhysicalResourceId 100 | var NormalReply = replyWithFunctor(sendResponse) 101 | 102 | console.log('REQUEST RECEIVED:\n', JSON.stringify(event)) 103 | 104 | function replyOrLongRunning() { 105 | var longRunningConf = resourceDefinition.LongRunning 106 | console.log('Checking for long running configs...') 107 | if (longRunningConf && 108 | longRunningConf.PingInSeconds && 109 | longRunningConf.MaxPings && 110 | longRunningConf.LambdaApi && 111 | longRunningConf.Methods && 112 | 'function' === typeof longRunningConf.Methods[RequestType]) { 113 | console.log('Long running configurations found, ' + 114 | 'providing this callback instead of the normal reply ' + 115 | 'to CloudFormation for action %s.', RequestType) 116 | console.log('LongRunning configs: %j', longRunningConf) 117 | return replyWithFunctor(triggerLongRunningReply) 118 | } 119 | console.log('Did not find valid LongRunning configs, ' + 120 | 'proceed as normal req: %j', longRunningConf) 121 | return NormalReply 122 | } 123 | 124 | if (event.LongRunningRequestContext) { 125 | console.log('LongRunningRequestContext found, proceeding ' + 126 | 'with ping cycle logic: %j', event.LongRunningRequestContext) 127 | if (resourceDefinition.LongRunning && 128 | resourceDefinition.LongRunning.MaxPings <= 129 | event.LongRunningRequestContext.PassedPings) { 130 | console.error('Ping cycle on long running resource ' + 131 | 'checks exceeded, timeout failure.') 132 | return NormalReply('FATAL: LongRunning resource failed ' + 133 | 'to stabilize within MaxPings (' + 134 | resourceDefinition.LongRunning.MaxPings + ' of ' + 135 | resourceDefinition.LongRunning.PingInSeconds + ' seconds each)') 136 | } 137 | console.log('Inside LongRunning request ping cycle and not timed out, ' + 138 | 'diverting %s to handler with notDone callback supplied.', RequestType) 139 | if (RequestType === 'Create') { 140 | return resourceDefinition.LongRunning.Methods.Create( 141 | event.LongRunningRequestContext, 142 | Params, 143 | NormalReply, 144 | notDoneCallback) 145 | } else if (RequestType === 'Update') { 146 | return resourceDefinition.LongRunning.Methods.Update( 147 | event.LongRunningRequestContext, 148 | RequestPhysicalId, 149 | Params, 150 | OldParams, 151 | NormalReply, 152 | notDoneCallback) 153 | } else { 154 | return resourceDefinition.LongRunning.Methods.Delete( 155 | event.LongRunningRequestContext, 156 | RequestPhysicalId, 157 | Params, 158 | NormalReply, 159 | notDoneCallback) 160 | } 161 | } 162 | 163 | function notDoneCallback() { 164 | console.log('Got NotDone signal callback from implementation of ' + 165 | 'cfn-lambda resource, engaging another tick in the cycle.') 166 | triggerLongRunningReply(event.LongRunningRequestContext.RawResponse) 167 | } 168 | 169 | function triggerLongRunningReply(rawReplyResponse) { 170 | if (rawReplyResponse.Status === 'FAILED') { 171 | return sendResponse(rawReplyResponse) 172 | } 173 | console.log('Long running configurations found and ' + 174 | 'initialization sent SUCCESS, continuing with ' + 175 | 'recurse operation: %j', rawReplyResponse) 176 | event.LongRunningRequestContext = { 177 | RawResponse: rawReplyResponse, 178 | PhysicalResourceId: rawReplyResponse.PhysicalResourceId, 179 | Data: rawReplyResponse.Data, 180 | PassedPings: event.LongRunningRequestContext 181 | ? event.LongRunningRequestContext.PassedPings + 1 182 | : 0 183 | } 184 | console.log('In %s seconds, will recurse with event: %j', 185 | resourceDefinition.LongRunning.PingInSeconds, event) 186 | setTimeout(function() { 187 | console.log('PingInSeconds of %s seconds passed, recursing lambda with: %j', 188 | resourceDefinition.LongRunning.PingInSeconds, event) 189 | resourceDefinition.LongRunning.LambdaApi.invoke({ 190 | FunctionName: CfnLambdaFactory.Environment.LambdaArn, 191 | InvocationType: 'Event', 192 | // Still CloudWatch logs, just not req/res here 193 | LogType: 'None', 194 | Payload: JSON.stringify(event) 195 | }, function(invokeErr, invokeData) { 196 | if (invokeErr) { 197 | console.error('Was unable to trigger long running ' + 198 | 'pingback step: %j', invokeErr.message) 199 | return NormalReply('Was unable to trigger long running ' + 200 | 'pingback step: ' + invokeErr.message) 201 | } 202 | console.log('Triggered long running ping step: %j', invokeData) 203 | console.log('Terminating this lambda and allowing ' + 204 | ' lambda recursion to take over.') 205 | context.done() 206 | }) 207 | }, resourceDefinition.LongRunning.PingInSeconds * 1000) 208 | } 209 | 210 | var invalidation = ValidationCheck(Params, { 211 | Validate: resourceDefinition.Validate, 212 | Schema: resourceDefinition.Schema, 213 | SchemaPath: resourceDefinition.SchemaPath 214 | }) 215 | if (invalidation) { 216 | if (RequestType === 'Delete') { 217 | console.log('cfn-lambda: Got Delete with an invalidation, ' + 218 | 'tripping failsafe for ROLLBACK states and exiting with success.') 219 | return NormalReply() 220 | } 221 | console.log('cfn-lambda: Found an invalidation.') 222 | return NormalReply(invalidation) 223 | } 224 | if (RequestType === 'Create') { 225 | console.log('cfn-lambda: Delegating to Create handler.') 226 | return resourceDefinition.Create(Params, replyOrLongRunning('Create')) 227 | } 228 | if (RequestType === 'Update') { 229 | if (JSONDeepEquals(Params, OldParams)) { 230 | console.log('cfn-lambda: Delegating to NoUpdate handler, ' + 231 | 'or exiting with success (Update with unchanged params).') 232 | return 'function' === typeof resourceDefinition.NoUpdate 233 | ? resourceDefinition.NoUpdate(RequestPhysicalId, Params, NormalReply) 234 | : NormalReply(null, RequestPhysicalId) 235 | } 236 | if (Array.isArray(resourceDefinition.TriggersReplacement) && 237 | !PluckedEquality(resourceDefinition.TriggersReplacement, Params, OldParams)) { 238 | console.log('cfn-lambda: Caught Replacement trigger key change, ' + 239 | 'delegating to Create, Delete will be called on old resource ' + 240 | 'during UPDATE_COMPLETE_CLEANUP_IN_PROGRESS phase.') 241 | return resourceDefinition.Create(Params, replyOrLongRunning('Create')) 242 | } 243 | console.log('cfn-lambda: Delegating to Update handler.') 244 | return resourceDefinition.Update(RequestPhysicalId, 245 | Params, OldParams, replyOrLongRunning('Update')) 246 | } 247 | if (RequestType === 'Delete') { 248 | console.log('cfn-lambda: Delegating to Delete handler.') 249 | return resourceDefinition.Delete(RequestPhysicalId, Params, replyOrLongRunning('Delete')) 250 | } 251 | console.log('cfn-lambda: Uh oh! Called with unrecognized EventType!') 252 | return NormalReply('The impossible happend! ' + 253 | 'CloudFormation sent an unknown RequestType.') 254 | 255 | 256 | function replyWithFunctor(functor) { 257 | return function(err, physicalId, optionalData) { 258 | if (err) { 259 | return functor({ 260 | Status: 'FAILED', 261 | Reason: err.toString(), 262 | PhysicalResourceId: physicalId || 263 | RequestPhysicalId || 264 | [event.StackId, event.LogicalResourceId, event.RequestId].join('/'), 265 | StackId: event.StackId, 266 | RequestId: event.RequestId, 267 | LogicalResourceId: event.LogicalResourceId, 268 | Data: optionalData 269 | }) 270 | } 271 | return functor({ 272 | Status: 'SUCCESS', 273 | PhysicalResourceId: physicalId || 274 | RequestPhysicalId || 275 | [event.StackId, event.LogicalResourceId, event.RequestId].join('/'), 276 | StackId: event.StackId, 277 | RequestId: event.RequestId, 278 | LogicalResourceId: event.LogicalResourceId, 279 | Data: optionalData || OldParams 280 | }) 281 | } 282 | } 283 | 284 | 285 | function sendResponse(response) { 286 | 287 | var responseBody = JSON.stringify(response) 288 | 289 | console.log('RESPONSE: %j', response) 290 | 291 | console.log('REPLYING TO: %s', event.ResponseURL) 292 | var parsedUrl = Url.parse(event.ResponseURL) 293 | var options = { 294 | hostname: parsedUrl.hostname, 295 | port: parsedUrl.port || 443, 296 | path: parsedUrl.path, 297 | rejectUnauthorized: parsedUrl.hostname !== 'localhost', 298 | method: 'PUT', 299 | headers: { 300 | 'Content-Type': '', 301 | 'Content-Length': responseBody.length 302 | } 303 | } 304 | 305 | if (parsedUrl.hostname === 'localhost') { 306 | options.rejectUnauthorized = false 307 | } 308 | 309 | var request = Https.request(options, function(response) { 310 | console.log('STATUS: %s',response.statusCode) 311 | console.log('HEADERS: %j', response.headers) 312 | response.on('data', function() { 313 | // noop 314 | }) 315 | response.on('end', function() { 316 | // Tell AWS Lambda that the function execution is done 317 | context.done() 318 | }) 319 | }) 320 | 321 | request.on('error', function(error) { 322 | console.log('sendResponse Error:\n', error) 323 | // Tell AWS Lambda that the function execution is done 324 | context.done() 325 | }) 326 | 327 | // write data to request body 328 | request.write(responseBody) 329 | request.end() 330 | } 331 | 332 | } 333 | } 334 | 335 | 336 | CfnLambdaFactory.SDKAlias = SDKAlias 337 | CfnLambdaFactory.ValidationCheck = ValidationCheck 338 | CfnLambdaFactory.JSONDeepEquals = JSONDeepEquals 339 | CfnLambdaFactory.PluckedEquality = PluckedEquality 340 | CfnLambdaFactory.DefaultExpander = DefaultExpander 341 | CfnLambdaFactory.Composite = Composite 342 | CfnLambdaFactory.Module = Composite.Module 343 | module.exports = CfnLambdaFactory 344 | 345 | 346 | 347 | 348 | 349 | module.exports.deploy = require('./deploy') 350 | -------------------------------------------------------------------------------- /deploy.js: -------------------------------------------------------------------------------- 1 | const archiver = require('archiver') 2 | const async = require('async') 3 | const AWS = require('aws-sdk') 4 | const nanoArgv = require('nano-argv') 5 | const path = require('path') 6 | const stream = require('stream') 7 | 8 | const regions = require('./lib/lambda.regions.json') 9 | const template = require('./lib/cfn-template.json') 10 | 11 | const DEFAULT_RUNTIME = 'nodejs10.x' 12 | 13 | const defaults = { 14 | account: null, 15 | alias: null, 16 | allregions: false, 17 | logs: false, 18 | module: null, 19 | path: null, 20 | public: false, 21 | quiet: false, 22 | regions: process.env.AWS_REGION || '', 23 | rollback: true, 24 | version: null, 25 | runtime: DEFAULT_RUNTIME 26 | } 27 | 28 | AWS.config.region = process.env.AWS_REGION || 'us-east-1' 29 | 30 | module.exports = CfnResourceDeploy 31 | 32 | if (require.main === module) { 33 | defaults.logs = true 34 | const opts = nanoArgv(defaults) 35 | opts.regions = opts.regions.split(',') 36 | CfnResourceDeploy(opts, (err, results) => { 37 | if (err) { 38 | console.error('') 39 | } 40 | }) 41 | } 42 | 43 | function CfnResourceDeploy (options, deployDone) { 44 | options = options || {} 45 | 46 | const log = logger('log') 47 | const error = logger('error') 48 | const resourceTypeDir = options.path 49 | ? path.resolve(options.path) 50 | : options.module 51 | ? path.resolve(process.cwd(), 'node_modules', options.module) 52 | : path.resolve(__dirname, '..', '..') 53 | const resourceTypeInfo = require(path.resolve(resourceTypeDir, 'package.json')) 54 | const resourceTypeName = options.alias || resourceTypeInfo.name 55 | const resourceTypeVersion = options.version || resourceTypeInfo.version.replace(/\./g, '-') 56 | const policy = require(path.resolve(resourceTypeDir, 'execution-policy.json')) 57 | template.Resources.ServiceLambdaRolePolicy.Properties.PolicyDocument = policy 58 | template.Description = `Custom resource type installer stack for ${resourceTypeName}-${resourceTypeVersion}` 59 | 60 | log('Zipping code bundle...') 61 | zip(resourceTypeDir, (err, zippedCodeBuffer) => { 62 | if (err) { 63 | error('Fatal error: Could not zip: ') 64 | error(err) 65 | return deployDone(err) 66 | } 67 | log('Zip complete, acquiring account ID...') 68 | getAccountId((err, accountId) => { 69 | if (err) { 70 | error('Fatal error: Could not acquire account ID (set manually with --account): ') 71 | error(err) 72 | return deployDone(err) 73 | } 74 | log(`Account ID set to ${accountId}`) 75 | options.account = accountId 76 | log('Deploying region set: ') 77 | if (options.allregions) { 78 | options.regions = regions.map(region => { 79 | return region.identifier 80 | }) 81 | } 82 | log(options.regions) 83 | async.each(options.regions, deployRegion, (err) => { 84 | if (err) { 85 | error('Problem deploying to the regions:') 86 | error(err) 87 | return deployDone(err) 88 | } 89 | log('Finished deploying to regions. Your custom resource is ready with ServiceToken:') 90 | log(`aws:arn::${options.account}:function:${resourceTypeName}-${resourceTypeVersion}`) 91 | deployDone() 92 | }) 93 | }) 94 | function deployRegion (region, regionDone) { 95 | // Make bucket 96 | // Upload function code 97 | // Upload cloudformation template 98 | // Upload launcher 99 | // Invoke CloudFormation 100 | // Wait for finish 101 | async.waterfall([ 102 | upsertBucket, 103 | uploadLambdaFunctionCode, 104 | uploadCloudFormationTemplate, 105 | uploadLauncherPage, 106 | invokeCloudFormation, 107 | // waitOnCloudFormation 108 | ], function (err, result) { 109 | if (err) { 110 | error(`${region} - ERROR with deploy: `) 111 | error(err) 112 | return regionDone(err) 113 | } 114 | log(`${region} - Finished deploying region.`) 115 | regionDone() 116 | }) 117 | function upsertBucket (bucketDone) { 118 | const RegionalAWS = require('aws-sdk') 119 | RegionalAWS.config.region = region 120 | const regionalS3 = new RegionalAWS.S3() 121 | const bucketName = getBucketName(region) 122 | log(`${region} - Upserting bucket ${bucketName}...`) 123 | regionalS3.createBucket({ 124 | Bucket: bucketName, 125 | }, (err, data) => { 126 | if (err && err.code !='BucketAlreadyOwnedByYou') { 127 | error(`${region} - ERROR deploying regional bucket:`) 128 | error(err) 129 | return bucketDone(err) 130 | } 131 | log(`${region} - Upserted regional bucket: ${bucketName}`) 132 | if (!options.public) { 133 | log(`${region} - Completed deploying regional bucket: ${bucketName}`) 134 | return bucketDone() 135 | } 136 | log(`${region} - Updating bucket policy to public...`) 137 | regionalS3.putBucketPolicy({ 138 | Bucket: bucketName, 139 | Policy: JSON.stringify({ 140 | "Version":"2012-10-17", 141 | "Statement": [ 142 | { 143 | "Sid": "AddPerm", 144 | "Effect": "Allow", 145 | "Principal": "*", 146 | "Action": [ 147 | "s3:GetObject" 148 | ], 149 | "Resource": [ 150 | `arn:aws:s3:::${bucketName}/*` 151 | ] 152 | } 153 | ] 154 | }) 155 | }, (err, data) => { 156 | if (err) { 157 | error(`ERROR setting bucket policy to public:`) 158 | error(err) 159 | return bucketDone(err) 160 | } 161 | log(`${region} - Completed putting public bucket policy on regional bucket: ${bucketName}`) 162 | log(`${region} - Completed deploying regional bucket: ${bucketName}`) 163 | bucketDone() 164 | }) 165 | }) 166 | } 167 | function uploadLambdaFunctionCode (codeUploadDone) { 168 | const RegionalAWS = require('aws-sdk') 169 | RegionalAWS.config.region = region 170 | const regionalS3 = new RegionalAWS.S3() 171 | log(`${region} - Uploading Lambda function code...`) 172 | regionalS3.putObject({ 173 | Bucket: getBucketName(region), 174 | Key: `${resourceTypeVersion}.zip`, 175 | Body: zippedCodeBuffer 176 | }, (err, data) => { 177 | if (err) { 178 | error(`${region} - ERROR uploading Lambda code:`) 179 | error(err) 180 | return codeUploadDone(err) 181 | } 182 | log(`${region} - Lambda code uploaded.`) 183 | codeUploadDone() 184 | }) 185 | } 186 | function uploadCloudFormationTemplate (cfnUploadDone) { 187 | const RegionalAWS = require('aws-sdk') 188 | RegionalAWS.config.region = region 189 | const regionalS3 = new RegionalAWS.S3() 190 | log(`${region} - Uploading CloudFormation template...`) 191 | regionalS3.putObject({ 192 | Bucket: getBucketName(region), 193 | Key: `${resourceTypeVersion}.json`, 194 | Body: JSON.stringify(template) 195 | }, (err, data) => { 196 | if (err) { 197 | error(`${region} - PROBLEM uploading CloudFormation template:`) 198 | error(err) 199 | return cfnUploadDone(err) 200 | } 201 | log(`${region} - CloudFormation template uploaded.`) 202 | cfnUploadDone() 203 | }) 204 | } 205 | function uploadLauncherPage (launcherUploadDone) { 206 | const RegionalAWS = require('aws-sdk') 207 | RegionalAWS.config.region = region 208 | const regionalS3 = new RegionalAWS.S3() 209 | log(`${region} - Uploading launcher page...`) 210 | regionalS3.putObject({ 211 | Bucket: getBucketName(region), 212 | Key: `${resourceTypeVersion}.html`, 213 | Body: composeHtml(), 214 | ContentType: 'text/html' 215 | }, (err, data) => { 216 | if (err) { 217 | error(`${region} - PROBLEM uploading launcher page to region:`) 218 | error(err) 219 | return launcherUploadDone(err) 220 | } 221 | log(`${region} - Launcher page uploaded.`) 222 | launcherUploadDone() 223 | }) 224 | } 225 | function invokeCloudFormation (invokeComplete) { 226 | const RegionalAWS = require('aws-sdk') 227 | RegionalAWS.config.region = region 228 | const regionalCloudFormation = new RegionalAWS.CloudFormation() 229 | log(`${region} - Creating CloudFormation stack...`) 230 | const cloudFormationParams = [ 231 | { 232 | ParameterKey: 'ResourceTypeName', 233 | ParameterValue: resourceTypeName 234 | }, 235 | { 236 | ParameterKey: 'ResourceTypeVersion', 237 | ParameterValue: resourceTypeVersion 238 | }, 239 | { 240 | ParameterKey: 'CodeBucket', 241 | ParameterValue: getBucketName(region) 242 | }, 243 | { 244 | ParameterKey: 'LambdaRuntimeVersion', 245 | ParameterValue: options.runtime || DEFAULT_RUNTIME 246 | } 247 | ] 248 | regionalCloudFormation.createStack({ 249 | StackName: `${resourceTypeName}-${resourceTypeVersion}`, 250 | Capabilities: [ 251 | 'CAPABILITY_IAM' 252 | ], 253 | DisableRollback: options.rollback === 'false' || !options.rollback, 254 | Parameters: cloudFormationParams, 255 | TemplateBody: JSON.stringify(template) 256 | }, (err, data) => { 257 | if (err) { 258 | if (err.code !== 'AlreadyExistsException') { 259 | error(`${region} - PROBLEM creating stack: `) 260 | error(err) 261 | return invokeComplete(err) 262 | } 263 | log(`${region} - Stack already existed, will update...`) 264 | return regionalCloudFormation.updateStack({ 265 | StackName: `${resourceTypeName}-${resourceTypeVersion}`, 266 | Capabilities: [ 267 | 'CAPABILITY_IAM' 268 | ], 269 | Parameters: cloudFormationParams, 270 | TemplateBody: JSON.stringify(template) 271 | }, (err, data) => { 272 | if (err && err.message !== 'No updates are to be performed.') { 273 | error(`${region} - PROBLEM updating stack: `) 274 | error(err) 275 | return invokeComplete(err) 276 | } 277 | log(`${region} - Stack updated.`) 278 | invokeComplete() 279 | }) 280 | } 281 | log(`${region} - Stack created.`) 282 | invokeComplete() 283 | }) 284 | } 285 | } 286 | function regionLauncherUrl(region) { 287 | const s3Host = region.identifier === 'us-east-1' 288 | ? 's3.amazonaws.com' 289 | : `s3-${region.identifier}.amazonaws.com` 290 | return `https://${region.identifier}.console.aws.amazon.com/cloudformation/home?region=${region.identifier}#/stacks/create/review?templateURL=https://${s3Host}/${getBucketName(region.identifier)}/${resourceTypeVersion}.json&stackName=${resourceTypeName}-${resourceTypeVersion}¶m_ResourceTypeName=${resourceTypeName}¶m_ResourceTypeVersion=${resourceTypeVersion}¶m_CodeBucket=${getBucketName(region.identifier)}` 291 | } 292 | function composeHtml () { 293 | var attributions = '' 294 | if ('string' === typeof resourceTypeInfo.author) { 295 | attributions += `

By: ${resourceTypeInfo.author}

` 296 | } 297 | if (resourceTypeInfo.description) { 298 | attributions += `

${resourceTypeInfo.description}

` 299 | } 300 | if (resourceTypeInfo.homepage) { 301 | attributions += `

Homepage: ${resourceTypeInfo.homepage}

` 302 | } 303 | if (resourceTypeInfo.license) { 304 | attributions += `

License: ${resourceTypeInfo.license}

` 305 | } 306 | 307 | return `Deploy ${resourceTypeName} v${resourceTypeVersion}` + 308 | '

CloudFormation Custom Resource Installer

' + 309 | `

${resourceTypeName} v${resourceTypeVersion}

` + 310 | attributions + 311 | '

Regional Launchers

' + 312 | `` + 313 | '' 314 | function regionLine(region) { 315 | return `
  • ${region.identifier} / ${region.name} : Launch
  • ` 316 | } 317 | function isLive (region) { 318 | return !!~options.regions.indexOf(region.identifier) 319 | } 320 | } 321 | }) 322 | function getBucketName (region) { 323 | return `${resourceTypeName}-${options.account}-${region}` 324 | } 325 | function logger (type) { 326 | return (content) => { 327 | if (options.logs && !options.quiet) { 328 | console[type](content) 329 | } 330 | } 331 | } 332 | function getAccountId (idDone) { 333 | if (options.account) { 334 | log(`Account ID was manually set as: ${options.account}`) 335 | return idDone(null, options.account) 336 | } 337 | log('Calling sts:GetCallerIdentity to see ARN and AccountId...') 338 | new AWS.STS().getCallerIdentity({}, (err, callerIdentity) => { 339 | if (err) { 340 | error('Could not complete sts:GetCallerIdentity.') 341 | error(err) 342 | return idDone(err) 343 | } 344 | log('Executed sts:GetCallerIdentity: ') 345 | log(callerIdentity) 346 | idDone(null, callerIdentity.Arn.replace(/arn:aws:.*::(.*?):.*/, '$1')) 347 | }) 348 | } 349 | } 350 | 351 | function zip(zippableDir, zipDone) { 352 | const zipChunks = []; 353 | const archive = archiver('zip'); 354 | var converter = new stream.Writable({ 355 | write: function (chunk, encoding, next) { 356 | zipChunks.push(chunk); 357 | next() 358 | }}); 359 | converter.on('finish', function () { 360 | zipDone(null, Buffer.concat(zipChunks)) 361 | }) 362 | converter.on('error', zipDone) 363 | archive.on('error', zipDone) 364 | archive.directory(zippableDir, '') 365 | archive.pipe(converter) 366 | archive.finalize() 367 | } 368 | -------------------------------------------------------------------------------- /test/longRunning.js: -------------------------------------------------------------------------------- 1 | 2 | var path = require('path'); 3 | var assert = require('assert'); 4 | 5 | var Server = require(path.resolve(__dirname, '..', 'test-helpers', 'https', 'server')); 6 | var ContextStub = require(path.resolve(__dirname, '..', 'test-helpers', 'context')); 7 | 8 | var CfnLambda = require(path.resolve(__dirname, '..', 'index')); 9 | 10 | 11 | 12 | 13 | describe('LongRunning', function() { 14 | var expectedUrl = '/foo/bar/taco'; 15 | var expectedStackId = 'fakeStackId'; 16 | var expectedRequestId = 'fakeRequestId'; 17 | var expectedLogicalResourceId = 'MyTestResource'; 18 | function HollowRequest() { 19 | return { 20 | ResponseURL: 'https://localhost:13002' + expectedUrl, 21 | StackId: expectedStackId, 22 | RequestId: expectedRequestId, 23 | ResourceType: 'Custom::TestResource', 24 | LogicalResourceId: expectedLogicalResourceId 25 | }; 26 | } 27 | it('should die when exceeding MaxPings', function(done) { 28 | var CfnRequest = HollowRequest(); 29 | var expectedStatus = 'FAILED'; 30 | var expectedPhysicalId = [ 31 | expectedStackId, 32 | expectedLogicalResourceId, 33 | expectedRequestId 34 | ].join('/'); 35 | var Lambda = CfnLambda({ 36 | LongRunning: { 37 | MaxPings: 2, 38 | PingInSeconds: 60 39 | } 40 | }); 41 | var expectedMaxPingError = 'FATAL: LongRunning resource failed ' + 42 | 'to stabilize within MaxPings (2 of 60 seconds each)'; 43 | 44 | CfnRequest.RequestType = 'Create'; 45 | CfnRequest.LongRunningRequestContext = { 46 | PassedPings: 2 47 | }; 48 | 49 | Server.on(function() { 50 | Lambda(CfnRequest, ContextStub); 51 | }, function(cfnResponse) { 52 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 53 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 54 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 55 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 56 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 57 | assert(expectedPhysicalId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 58 | assert(expectedMaxPingError === cfnResponse.body.Reason, 'Bad PingMax failure message: ' + cfnResponse.body.Reason); 59 | done(); 60 | }); 61 | 62 | }); 63 | 64 | it('should call LongRunning.Methods.Create when Create + non-exceeded PingMax', function(done) { 65 | var CfnRequest = HollowRequest(); 66 | var expectedPhysicalId = 'foobar'; 67 | CfnRequest.RequestType = 'Create'; 68 | CfnRequest.LongRunningRequestContext = { 69 | PassedPings: 1 70 | }; 71 | var expectedStatus = 'SUCCESS'; 72 | var expectedPhysicalId = 'someValueProvided'; 73 | var Lambda = CfnLambda({ 74 | LongRunning: { 75 | PingInSeconds: 60, 76 | MaxPings: 2, 77 | Methods: { 78 | Create: function(rawContext, params, reply) { 79 | assert(rawContext.PassedPings === 1, 'Did not receieve LongRunningRequestContext'); 80 | reply(null, expectedPhysicalId); 81 | } 82 | } 83 | } 84 | }); 85 | 86 | Server.on(function() { 87 | Lambda(CfnRequest, ContextStub); 88 | }, function(cfnResponse) { 89 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 90 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 91 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 92 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 93 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 94 | assert(expectedPhysicalId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 95 | done(); 96 | }); 97 | 98 | }); 99 | 100 | it('should call LongRunning.Methods.Delete when Delete + non-exceeded PingMax', function(done) { 101 | var CfnRequest = HollowRequest(); 102 | var expectedPhysicalId = 'foobar'; 103 | CfnRequest.RequestType = 'Delete'; 104 | CfnRequest.LongRunningRequestContext = { 105 | PassedPings: 1 106 | }; 107 | var expectedStatus = 'SUCCESS'; 108 | var Lambda = CfnLambda({ 109 | LongRunning: { 110 | PingInSeconds: 60, 111 | MaxPings: 2, 112 | Methods: { 113 | Delete: function(rawContext, physicalId, params, reply) { 114 | assert(rawContext.PassedPings === 1, 'Did not receieve LongRunningRequestContext'); 115 | reply(null, expectedPhysicalId); 116 | } 117 | } 118 | } 119 | }); 120 | 121 | Server.on(function() { 122 | Lambda(CfnRequest, ContextStub); 123 | }, function(cfnResponse) { 124 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 125 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 126 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 127 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 128 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 129 | assert(expectedPhysicalId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 130 | done(); 131 | }); 132 | 133 | }); 134 | 135 | it('should call LongRunning.Methods.Update when Update + non-exceeded PingMax', function(done) { 136 | var CfnRequest = HollowRequest(); 137 | var expectedPhysicalId = 'foobar'; 138 | CfnRequest.RequestType = 'Update'; 139 | CfnRequest.LongRunningRequestContext = { 140 | PassedPings: 1 141 | }; 142 | var expectedStatus = 'SUCCESS'; 143 | var Lambda = CfnLambda({ 144 | LongRunning: { 145 | PingInSeconds: 60, 146 | MaxPings: 2, 147 | Methods: { 148 | Update: function(rawContext, physicalId, params, oldParams, reply) { 149 | assert(rawContext.PassedPings === 1, 'Did not receieve LongRunningRequestContext'); 150 | reply(null, expectedPhysicalId); 151 | } 152 | } 153 | } 154 | }); 155 | 156 | Server.on(function() { 157 | Lambda(CfnRequest, ContextStub); 158 | }, function(cfnResponse) { 159 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 160 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 161 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 162 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 163 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 164 | assert(expectedPhysicalId === cfnResponse.body.PhysicalResourceId, 'Bad PhysicalResourceId'); 165 | done(); 166 | }); 167 | 168 | }); 169 | 170 | it('should delegate to NormalHandler when FAILED in normal CRUD even with LongRunning configured', function(done) { 171 | var CfnRequest = HollowRequest(); 172 | var expectedReason = 'Random Error to trigger failure'; 173 | CfnRequest.RequestType = 'Create'; 174 | var expectedStatus = 'FAILED'; 175 | var Lambda = CfnLambda({ 176 | Create: function(params, reply) { 177 | reply(expectedReason); 178 | }, 179 | LongRunning: { 180 | PingInSeconds: 60, 181 | MaxPings: 2, 182 | LambdaApi: {}, 183 | Methods: { 184 | Create: function(rawContext, params, reply) { 185 | // Doesn't matter, never hits in this test, just need a function here. 186 | throw new Error('SHOULD NOT HIT THIS CREATE PINGBACK FUNCTION'); 187 | } 188 | } 189 | } 190 | }); 191 | 192 | Server.on(function() { 193 | Lambda(CfnRequest, ContextStub); 194 | }, function(cfnResponse) { 195 | assert(expectedUrl === cfnResponse.url, 'Bad publish URL'); 196 | assert(expectedStatus === cfnResponse.body.Status, 'Bad Status'); 197 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 198 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 199 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 200 | assert(expectedReason === cfnResponse.body.Reason, 'Bad Reason'); 201 | done(); 202 | }); 203 | 204 | }); 205 | 206 | it('should hit LambdaApi.invoke on SUCCESS w/ normal CRUD w/ LongRunning configured', function(done) { 207 | var CfnRequest = HollowRequest(); 208 | var expectedReason = 'Random Error to trigger failure'; 209 | var expectedPhysicalId = 'foobar'; 210 | CfnRequest.RequestType = 'Create'; 211 | var expectedStatus = 'SUCCESS'; 212 | var signalStart = Date.now(); 213 | var pingDelaySeconds = 1; 214 | var Lambda = CfnLambda({ 215 | Create: function(params, reply) { 216 | reply(null, expectedPhysicalId); 217 | }, 218 | LongRunning: { 219 | PingInSeconds: pingDelaySeconds, 220 | MaxPings: 2, 221 | LambdaApi: { 222 | invoke: function(rawInvocation, respondToSpawningLambda) { 223 | var elapsedMillis = Date.now() - signalStart; 224 | var invocation = JSON.parse(rawInvocation.Payload); 225 | var rawResponse = invocation.LongRunningRequestContext.RawResponse; 226 | assert(elapsedMillis > pingDelaySeconds * 1000); 227 | assert(CfnRequest.ResponseURL === invocation.ResponseURL, 'Bad publish URL: ' + invocation.ResponseURL); 228 | assert(expectedStatus === rawResponse.Status, 'Bad Status'); 229 | assert(expectedStackId === rawResponse.StackId, 'Bad StackID'); 230 | assert(expectedRequestId === rawResponse.RequestId, 'Bad RequestId'); 231 | assert(expectedLogicalResourceId === rawResponse.LogicalResourceId, 'Bad LogicalResourceId'); 232 | assert(expectedPhysicalId === rawResponse.PhysicalResourceId); 233 | assert(invocation.LongRunningRequestContext.PassedPings === 0); 234 | assert(rawInvocation.FunctionName === ContextStub.invokedFunctionArn); 235 | respondToSpawningLambda(null, { 236 | statusCode: 202, 237 | message: '' 238 | }); 239 | } 240 | }, 241 | Methods: { 242 | Create: function(rawContext, params, reply) { 243 | // Doesn't matter, never hits in this test, just need a function here. 244 | throw new Error('SHOULD NOT HIT THIS CREATE PINGBACK FUNCTION'); 245 | } 246 | } 247 | } 248 | }); 249 | 250 | Server.on(function() { 251 | Lambda(CfnRequest, { 252 | done: function() { 253 | done(); 254 | }, 255 | invokedFunctionArn: ContextStub.invokedFunctionArn 256 | }); 257 | }, function(cfnResponse) { 258 | throw new Error('SHOULD NOT HIT S3 STUB'); 259 | }); 260 | 261 | }); 262 | 263 | it('should handle LambdaApi.invoke failures as hard FAILED to S3 requests', function(done) { 264 | var CfnRequest = HollowRequest(); 265 | var expectedReason = 'Random Error to trigger failure'; 266 | var expectedPhysicalId = 'foobar'; 267 | CfnRequest.RequestType = 'Create'; 268 | var expectedStatus = 'SUCCESS'; 269 | var signalStart = Date.now(); 270 | var pingDelaySeconds = 1; 271 | var Lambda = CfnLambda({ 272 | Create: function(params, reply) { 273 | reply(null, expectedPhysicalId); 274 | }, 275 | LongRunning: { 276 | PingInSeconds: pingDelaySeconds, 277 | MaxPings: 2, 278 | LambdaApi: { 279 | invoke: function(rawInvocation, respondToSpawningLambda) { 280 | var elapsedMillis = Date.now() - signalStart; 281 | var invocation = JSON.parse(rawInvocation.Payload); 282 | var rawResponse = invocation.LongRunningRequestContext.RawResponse; 283 | assert(elapsedMillis > pingDelaySeconds * 1000); 284 | assert(CfnRequest.ResponseURL === invocation.ResponseURL, 'Bad publish URL: ' + invocation.ResponseURL); 285 | assert(expectedStatus === rawResponse.Status, 'Bad Status'); 286 | assert(expectedStackId === rawResponse.StackId, 'Bad StackID'); 287 | assert(expectedRequestId === rawResponse.RequestId, 'Bad RequestId'); 288 | assert(expectedLogicalResourceId === rawResponse.LogicalResourceId, 'Bad LogicalResourceId'); 289 | assert(expectedPhysicalId === rawResponse.PhysicalResourceId); 290 | assert(invocation.LongRunningRequestContext.PassedPings === 0); 291 | assert(rawInvocation.FunctionName === ContextStub.invokedFunctionArn); 292 | respondToSpawningLambda({ 293 | statusCode: 500, 294 | message: 'You suck!' 295 | }); 296 | } 297 | }, 298 | Methods: { 299 | Create: function(rawContext, params, reply) { 300 | // Doesn't matter, never hits in this test, just need a function here. 301 | throw new Error('SHOULD NOT HIT THIS CREATE PINGBACK FUNCTION'); 302 | } 303 | } 304 | } 305 | }); 306 | 307 | Server.on(function() { 308 | Lambda(CfnRequest, ContextStub); 309 | }, function(cfnResponse) { 310 | assert('FAILED' === cfnResponse.body.Status, 'Bad Status'); 311 | assert(expectedStackId === cfnResponse.body.StackId, 'Bad StackID'); 312 | assert(expectedRequestId === cfnResponse.body.RequestId, 'Bad RequestId'); 313 | assert(expectedLogicalResourceId === cfnResponse.body.LogicalResourceId, 'Bad LogicalResourceId'); 314 | assert(cfnResponse.body.Reason === 'Was unable to trigger long running ' + 315 | 'pingback step: You suck!', 'Bad Reason: ' + cfnResponse.body.Reason); 316 | done(); 317 | }); 318 | 319 | }); 320 | 321 | it('should handle LambdaApi.invoke failures as hard FAILED to S3 requests', function(done) { 322 | var CfnRequest = HollowRequest(); 323 | var expectedReason = 'Random Error to trigger failure'; 324 | var expectedPhysicalId = 'foobar'; 325 | CfnRequest.RequestType = 'Create'; 326 | CfnRequest.LongRunningRequestContext = { 327 | PassedPings: 1, 328 | RawResponse: { 329 | Status: 'SUCCESS', 330 | StackId: CfnRequest.StackId, 331 | RequestId: CfnRequest.RequestId, 332 | LogicalResourceId: CfnRequest.LogicalResourceId, 333 | PhysicalResourceId: expectedPhysicalId 334 | } 335 | }; 336 | var expectedStatus = 'SUCCESS'; 337 | var signalStart = Date.now(); 338 | var pingDelaySeconds = 1; 339 | var Lambda = CfnLambda({ 340 | Create: function(params, reply) { 341 | reply(null, expectedPhysicalId); 342 | }, 343 | LongRunning: { 344 | PingInSeconds: pingDelaySeconds, 345 | MaxPings: 2, 346 | LambdaApi: { 347 | invoke: function(rawInvocation, respondToSpawningLambda) { 348 | var elapsedMillis = Date.now() - signalStart; 349 | var invocation = JSON.parse(rawInvocation.Payload); 350 | var rawResponse = invocation.LongRunningRequestContext.RawResponse; 351 | assert(elapsedMillis > pingDelaySeconds * 1000); 352 | assert(CfnRequest.ResponseURL === invocation.ResponseURL, 'Bad publish URL: ' + invocation.ResponseURL); 353 | assert(expectedStatus === rawResponse.Status, 'Bad Status'); 354 | assert(expectedStackId === rawResponse.StackId, 'Bad StackID'); 355 | assert(expectedRequestId === rawResponse.RequestId, 'Bad RequestId'); 356 | assert(expectedLogicalResourceId === rawResponse.LogicalResourceId, 'Bad LogicalResourceId'); 357 | assert(expectedPhysicalId === rawResponse.PhysicalResourceId, 'Bad PhysicalResourceId'); 358 | assert(invocation.LongRunningRequestContext.PassedPings === 2, 'Did not increase PassedPings'); 359 | assert(rawInvocation.FunctionName === ContextStub.invokedFunctionArn, 'Broke Lambda ARN'); 360 | respondToSpawningLambda(null, { 361 | statusCode: 202, 362 | message: '' 363 | }); 364 | done(); 365 | } 366 | }, 367 | Methods: { 368 | Create: function(rawContext, params, reply, notDone) { 369 | // Doesn't matter, never hits in this test, just need a function here. 370 | notDone(); 371 | } 372 | } 373 | } 374 | }); 375 | 376 | Server.on(function() { 377 | Lambda(CfnRequest, ContextStub); 378 | }, function(cfnResponse) { 379 | throw new Error('SHOULD NOT GET TO S3 SIGNED PUT!'); 380 | }); 381 | 382 | }); 383 | 384 | }); 385 | -------------------------------------------------------------------------------- /test/sdk.alias.js: -------------------------------------------------------------------------------- 1 | 2 | var path = require('path'); 3 | var assert = require('assert'); 4 | 5 | var SDKAlias = require(path.resolve(__dirname, '..', 'index')).SDKAlias; 6 | 7 | function noop() {}; 8 | 9 | describe('SDKAlias', function() { 10 | 11 | describe('Sanity', function() { 12 | it('should throw an exception when called with incorrect signature', function(done) { 13 | var Alias = SDKAlias({}); 14 | var expectedErrorMessage = 'Could not determine cfn-lambda ' + 15 | 'SDKAlias method signature at runtime.'; 16 | var actualErrorMessage; 17 | try { 18 | Alias('too', 'many', 'runtime', 'arguments', 'silly'); 19 | } catch (err) { 20 | actualErrorMessage = err.message; 21 | } 22 | assert(actualErrorMessage === expectedErrorMessage); 23 | done(); 24 | }); 25 | it('should not fail when there are no Properties parameters', function(done) { 26 | var Alias = SDKAlias({ 27 | api: { 28 | test: function(finalParams, fakeReply) { 29 | assert(!Object.keys(finalParams).length); 30 | done(); 31 | } 32 | }, 33 | method: 'test' 34 | }); 35 | 36 | Alias(undefined, noop); 37 | }); 38 | }); 39 | 40 | describe('Param Manipulation', function() { 41 | it('should map the physical ID when physicalIdAs is provided', function(done) { 42 | var providedPhyscialId = 'foobar'; 43 | var Alias = SDKAlias({ 44 | physicalIdAs: 'newNameForId', 45 | api: { 46 | test: function(finalParams, fakeReply) { 47 | assert(finalParams.newNameForId === providedPhyscialId); 48 | done(); 49 | } 50 | }, 51 | method: 'test' 52 | }); 53 | 54 | Alias(providedPhyscialId, {}, noop); 55 | }); 56 | 57 | it('should include only passed keyset', function(done) { 58 | var providedPhyscialId = 'foobar'; 59 | var Alias = SDKAlias({ 60 | physicalIdAs: 'newNameForId', 61 | keys: ['Passed'], 62 | api: { 63 | test: function(finalParams, fakeReply) { 64 | assert(Object.keys(finalParams).length === 1); 65 | assert(finalParams.Passed === 'key'); 66 | done(); 67 | } 68 | }, 69 | method: 'test' 70 | }); 71 | 72 | Alias(providedPhyscialId, { 73 | Has: 'many', 74 | Passed: 'key' 75 | }, noop); 76 | }); 77 | 78 | it('should downcase first letter in keys when downcase === true', function(done) { 79 | var providedPhyscialId = 'foobar'; 80 | var Alias = SDKAlias({ 81 | physicalIdAs: 'newNameForId', 82 | keys: ['Passed'], 83 | downcase: true, 84 | api: { 85 | test: function(finalParams, fakeReply) { 86 | assert(Object.keys(finalParams).length === 1); 87 | assert(finalParams.passed === 'key'); 88 | done(); 89 | } 90 | }, 91 | method: 'test' 92 | }); 93 | 94 | Alias(providedPhyscialId, { 95 | Has: 'many', 96 | Passed: 'key' 97 | }, 98 | { 99 | Plus: 'some', 100 | Old: 'params' 101 | }, noop); 102 | }); 103 | 104 | it('should map across keys with mapKeys hash', function(done) { 105 | var providedPhyscialId = 'foobar'; 106 | var Alias = SDKAlias({ 107 | mapKeys: { 108 | Has: 'Mapped' 109 | }, 110 | downcase: true, 111 | api: { 112 | test: function(finalParams, fakeReply) { 113 | assert(Object.keys(finalParams).length === 2, 'Wrong key count, found: ' + JSON.stringify(finalParams)); 114 | assert(finalParams.passed === 'key', 'Bad downcase, found: ' + finalParams.passed); 115 | assert(finalParams.mapped === 'many', 'Bad mapping, found: ' + finalParams.mapped); 116 | done(); 117 | } 118 | }, 119 | method: 'test' 120 | }); 121 | 122 | Alias(providedPhyscialId, { 123 | Has: 'many', 124 | Passed: 'key' 125 | }, 126 | { 127 | Plus: 'some', 128 | Old: 'params' 129 | }, noop); 130 | }); 131 | 132 | it('should force booleans along the forceBools path sets', function(done) { 133 | var providedPhyscialId = 'foobar'; 134 | var Alias = SDKAlias({ 135 | forceBools: [ 136 | 'Short.Unfulfilled', 137 | 'Long.Unfulfilled.Path', 138 | 'Short', 139 | 'Wildcard.*', 140 | 'Arr.Wildcard.*', 141 | 'Super.Long.Unfulfilled.Path' 142 | ], 143 | api: { 144 | test: function(finalParams, fakeReply) { 145 | 146 | assert(Object.keys(finalParams.Long).length === 1, 'A'); 147 | assert(finalParams.Long.Untouched === 'value', 'B'); 148 | 149 | assert(finalParams.Short === false, 'Short value: ' + typeof finalParams.Short); 150 | 151 | assert(Object.keys(finalParams.Wildcard).length === 2, 'D'); 152 | assert(finalParams.Wildcard.truthyString === true, 'E'); 153 | assert(finalParams.Wildcard.falseyInt === false, 'F'); 154 | 155 | assert(Object.keys(finalParams.Arr).length === 1, 'G'); 156 | assert(finalParams.Arr.Wildcard.length === 4, 'H'); 157 | assert(finalParams.Arr.Wildcard[0] === true, 'A'); 158 | assert(finalParams.Arr.Wildcard[1] === false, 'A'); 159 | assert(finalParams.Arr.Wildcard[2] === false, 'A'); 160 | assert(finalParams.Arr.Wildcard[3] === true, 'A'); 161 | 162 | assert(Object.keys(finalParams).length === 4, 'A'); 163 | 164 | done(); 165 | } 166 | }, 167 | method: 'test' 168 | }); 169 | 170 | Alias(providedPhyscialId, { 171 | Long: { 172 | Untouched: 'value' 173 | }, 174 | Short: 'false', 175 | Wildcard: { 176 | truthyString: 'true', 177 | falseyInt: '0' 178 | }, 179 | Arr: { 180 | Wildcard: [ 181 | 'true', 182 | 'false', 183 | 'null', 184 | '1' 185 | ] 186 | } 187 | }, noop); 188 | }); 189 | it('should force numbers along the forceNums path sets', function(done) { 190 | var providedPhyscialId = 'foobar'; 191 | var Alias = SDKAlias({ 192 | forceNums: [ 193 | 'Short.Unfulfilled', 194 | 'Long.Unfulfilled.Path', 195 | 'Short', 196 | 'Wildcard.*', 197 | 'Arr.Wildcard.*', 198 | 'Super.Long.Unfulfilled.Path' 199 | ], 200 | api: { 201 | test: function(finalParams, fakeReply) { 202 | 203 | assert(Object.keys(finalParams.Long).length === 1, 'A'); 204 | assert(finalParams.Long.Untouched === '1337', 'B'); 205 | 206 | assert(finalParams.Short === 1337, 'C'); 207 | 208 | assert(Object.keys(finalParams.Wildcard).length === 2, 'D'); 209 | assert(finalParams.Wildcard.truthyString === 1, 'E'); 210 | assert(finalParams.Wildcard.falseyInt === 0, 'F'); 211 | 212 | assert(Object.keys(finalParams.Arr).length === 1, 'G'); 213 | assert(finalParams.Arr.Wildcard.length === 4, 'H'); 214 | assert(finalParams.Arr.Wildcard[0] === 0, 'I'); 215 | assert(finalParams.Arr.Wildcard[1] === 1, 'J'); 216 | assert(finalParams.Arr.Wildcard[2] === 2, 'K'); 217 | assert(finalParams.Arr.Wildcard[3] === 3, 'L'); 218 | 219 | assert(Object.keys(finalParams).length === 4, 'M'); 220 | 221 | done(); 222 | } 223 | }, 224 | method: 'test' 225 | }); 226 | 227 | Alias(providedPhyscialId, { 228 | Long: { 229 | Untouched: '1337' 230 | }, 231 | Short: '1337', 232 | Wildcard: { 233 | truthyString: '1', 234 | falseyInt: 0 235 | }, 236 | Arr: { 237 | Wildcard: [ 238 | '0', 239 | '1', 240 | 2, 241 | '3' 242 | ] 243 | } 244 | }, noop); 245 | }); 246 | }); 247 | 248 | describe('Attribute Post-Processing', function() { 249 | it('should not yield any attributes with no returnAttrs defined', function(done) { 250 | var Alias = SDKAlias({ 251 | api: { 252 | test: function(finalParams, fakeReply) { 253 | // Pretending AWS-SDK replies with this to the node cb. 254 | fakeReply(null, { 255 | Big: 'hunk', 256 | Of: 'attributes' 257 | }); 258 | } 259 | }, 260 | method: 'test' 261 | }); 262 | 263 | Alias({ 264 | Has: 'many', 265 | Passed: 'key' 266 | }, function(err, physicalId, actualAttrHash) { 267 | assert(actualAttrHash === undefined); 268 | done(); 269 | }); 270 | 271 | }); 272 | 273 | it('should yield any attributes within returnAttrs', function(done) { 274 | var Alias = SDKAlias({ 275 | returnAttrs: ['Id', 'Big'], 276 | api: { 277 | test: function(finalParams, fakeReply) { 278 | // Pretending AWS-SDK replies with this to the node cb. 279 | fakeReply(null, { 280 | Big: 'hunk', 281 | Of: 'attributes', 282 | Id: 'foobar' 283 | }); 284 | } 285 | }, 286 | method: 'test' 287 | }); 288 | 289 | Alias({ 290 | Has: 'many', 291 | Passed: 'key' 292 | }, function(err, physicalId, actualAttrHash) { 293 | assert(Object.keys(actualAttrHash).length === 2); 294 | assert(actualAttrHash.Id === 'foobar'); 295 | assert(actualAttrHash.Big === 'hunk'); 296 | done(); 297 | }); 298 | 299 | }); 300 | 301 | it('should properly yield attrs with custom returnAttrs', function(done) { 302 | var expectedAttributeHashTestValue = 'working custom attributes'; 303 | var Alias = SDKAlias({ 304 | returnAttrs: function(data) { 305 | return { 306 | Test: ['working', 'custom', data.Of].join(' ') 307 | }; 308 | }, 309 | api: { 310 | test: function(finalParams, fakeReply) { 311 | // Pretending AWS-SDK replies with this to the node cb. 312 | fakeReply(null, { 313 | Big: 'hunk', 314 | Of: 'attributes', 315 | Id: 'foobar' 316 | }); 317 | } 318 | }, 319 | method: 'test' 320 | }); 321 | 322 | Alias({ 323 | Has: 'many', 324 | Passed: 'key' 325 | }, function(err, physicalId, actualAttrHash) { 326 | assert(Object.keys(actualAttrHash).length === 1); 327 | assert(actualAttrHash.Test === expectedAttributeHashTestValue); 328 | done(); 329 | }); 330 | 331 | }); 332 | }); 333 | 334 | describe('Aliased Service Error Handling', function() { 335 | it('should pass forward errors when not suppressing codes', function(done) { 336 | var expectedErrorMessage = 'Something bad happened within AWS.'; 337 | var Alias = SDKAlias({ 338 | api: { 339 | test: function(finalParams, fakeReply) { 340 | // Pretending AWS-SDK replies with this to the node cb. 341 | fakeReply({ 342 | statusCode: 400, 343 | message: expectedErrorMessage 344 | }); 345 | } 346 | }, 347 | method: 'test' 348 | }); 349 | 350 | Alias({ 351 | Has: 'many', 352 | Passed: 'key' 353 | }, function(err, physicalId, actualAttrHash) { 354 | assert(physicalId === undefined, 'physicalId should be undefined'); 355 | assert(actualAttrHash === undefined, 'returned attrs should be undefined'); 356 | assert(err === expectedErrorMessage, 'error message mismatch'); 357 | done(); 358 | }); 359 | }); 360 | 361 | it('should pass suppress errors within ignoreErrorCodes', function(done) { 362 | var Alias = SDKAlias({ 363 | ignoreErrorCodes: [404], 364 | api: { 365 | test: function(finalParams, fakeReply) { 366 | // Pretending AWS-SDK replies with this to the node cb. 367 | fakeReply({ 368 | statusCode: 404, 369 | message: 'This should be suppressed.' 370 | }); 371 | } 372 | }, 373 | method: 'test' 374 | }); 375 | 376 | Alias({ 377 | Has: 'many', 378 | Passed: 'key' 379 | }, function(err, physicalId, actualAttrHash) { 380 | assert(physicalId === undefined, 'physicalId should be undefined'); 381 | assert(actualAttrHash === undefined, 'returned attrs should be undefined'); 382 | assert(err == null, 'error message should be null, found: ' + err); 383 | done(); 384 | }); 385 | }); 386 | }); 387 | 388 | describe('Physical ID Generation', function() { 389 | it('should work with string mappable physicalId', function(done) { 390 | var expectedPhysicalId = 'Should be this value'; 391 | var expectedIncludedAttrValue = 'foobar'; 392 | var Alias = SDKAlias({ 393 | returnAttrs: ['IncludeInAttrs'], 394 | returnPhysicalId: 'UseMeAsId', 395 | api: { 396 | test: function(finalParams, fakeReply) { 397 | // Pretending AWS-SDK replies with this to the node cb. 398 | fakeReply(null, { 399 | UseMeAsId: expectedPhysicalId, 400 | IncludeInAttrs: expectedIncludedAttrValue 401 | }); 402 | } 403 | }, 404 | method: 'test' 405 | }); 406 | 407 | Alias({ 408 | Has: 'many', 409 | Passed: 'key' 410 | }, function(err, physicalId, actualAttrHash) { 411 | assert(physicalId === expectedPhysicalId, 'physicalId was wrong: ' + physicalId); 412 | assert(Object.keys(actualAttrHash).length === 1, 'returned attrs were wrong.'); 413 | assert(actualAttrHash.IncludeInAttrs === expectedIncludedAttrValue, 414 | 'returned attrs were wrong.'); 415 | assert(err == null, 'error message should be null, found: ' + err); 416 | done(); 417 | }); 418 | }); 419 | 420 | it('should work with string mappable physicalId with no returned values', function(done) { 421 | var Alias = SDKAlias({ 422 | ignoreErrorCodes: [404], 423 | returnPhysicalId: 'UseMeAsId', 424 | api: { 425 | test: function(finalParams, fakeReply) { 426 | // Pretending AWS-SDK replies with this to the node cb. 427 | fakeReply({ 428 | statusCode: 404, 429 | message: 'Valid case for having no hash to pull physicalId from' 430 | }); 431 | } 432 | }, 433 | method: 'test' 434 | }); 435 | 436 | Alias({ 437 | Has: 'many', 438 | Passed: 'key' 439 | }, function(err, physicalId, actualAttrHash) { 440 | assert(physicalId === undefined, 'physicalId was wrong: ' + physicalId); 441 | assert(actualAttrHash === undefined, 'returned attrs were wrong.'); 442 | assert(err == null, 'error message should be null, found: ' + err); 443 | done(); 444 | }); 445 | }); 446 | 447 | it('should work with custom function physicalId', function(done) { 448 | var physicalIdFragment = 'Should be this value'; 449 | var expectedIncludedAttrValue = 'foobar'; 450 | var expectedPhysicalId = [physicalIdFragment, expectedIncludedAttrValue].join(':'); 451 | var Alias = SDKAlias({ 452 | returnAttrs: ['IncludeInAttrs'], 453 | returnPhysicalId: function(data) { 454 | return data.UseMeInId + ':' + data.IncludeInAttrs; 455 | }, 456 | api: { 457 | test: function(finalParams, fakeReply) { 458 | // Pretending AWS-SDK replies with this to the node cb. 459 | fakeReply(null, { 460 | UseMeInId: physicalIdFragment, 461 | IncludeInAttrs: expectedIncludedAttrValue 462 | }); 463 | } 464 | }, 465 | method: 'test' 466 | }); 467 | 468 | Alias({ 469 | Has: 'many', 470 | Passed: 'key' 471 | }, function(err, physicalId, actualAttrHash) { 472 | assert(physicalId === expectedPhysicalId, 'physicalId was wrong: ' + physicalId); 473 | assert(Object.keys(actualAttrHash).length === 1, 'returned attrs were wrong.'); 474 | assert(actualAttrHash.IncludeInAttrs === expectedIncludedAttrValue, 475 | 'returned attrs were wrong.'); 476 | assert(err == null, 'error message should be null, found: ' + err); 477 | done(); 478 | }); 479 | }); 480 | }); 481 | 482 | }); 483 | -------------------------------------------------------------------------------- /src/Composite.js: -------------------------------------------------------------------------------- 1 | 2 | var path = require('path') 3 | var fs = require('fs') 4 | 5 | const Composite = ({ AWS, Composition, PingInSeconds, MaxPings, }) => { 6 | const CfnLambda = this 7 | const CFN = new AWS.CloudFormation() 8 | const RunComposition = Composition 9 | const TimeoutInMinutes = Math.ceil(PingInSeconds * MaxPings / 60) + 1 10 | const NoUpdate = (physicalId, params, reply) => { 11 | console.log('Entering NoUpdate for the composite resource, ' + 12 | 'checking substack representation outputs for: %s', physicalId) 13 | CFN.describeStacks({ 14 | StackName: physicalId 15 | }, function(getStackErr, stackData) { 16 | if (getStackErr) { 17 | console.error('During composite resource NoUpdate op on %s, ' + 18 | 'unable to pull context Outputs: %j', physicalId, getStackErr) 19 | return reply('FATAL: could not pull context: ' + 20 | (err.message || 'TOTAL_FAILURE')) 21 | } 22 | if (!stackData.Stacks.length) { 23 | console.error('During composite resource NoUpdate op on %s, ' + 24 | 'unable to pull context Outputs: (Not Found!)', physicalId) 25 | return reply('Could not find the composite resource stack.') 26 | } 27 | var outputHashFormatted = toGetAttFormat(stackData.Stacks[0].Outputs) 28 | console.log('Successfully acquired composite resource ' + 29 | 'substack %s output hash: %j', physicalId, outputHashFormatted) 30 | reply(null, physicalId, outputHashFormatted) 31 | }) 32 | } 33 | const Create = (params, reply) => { 34 | var ComposeInstance = new Composer() 35 | console.log('Entering composite resource CREATE action...') 36 | console.log('Running composite resource substack ' + 37 | 'composition function with params: %j', params) 38 | RunComposition(params, ComposeInstance, function(composeErr) { 39 | if (composeErr) { 40 | console.error('Error while composing composite resource' + 41 | 'substack representation for CREATE: %j', composeErr) 42 | return reply('FATAL substack composition error: ' + 43 | (composeErr.message || 'UNKNOWN_FATAL')) 44 | } 45 | var stackParams = { 46 | StackName: CfnLambda.Environment.LambdaName + Date.now(), 47 | Capabilities: [ 48 | 'CAPABILITY_IAM' 49 | ], 50 | OnFailure: 'DELETE', 51 | TemplateBody: ComposeInstance.Result(), 52 | TimeoutInMinutes: TimeoutInMinutes 53 | } 54 | console.log('Triggering substack representation launch of the ' + 55 | 'resource with: %j', stackParams) 56 | CFN.createStack(stackParams, function(createInitErr, createInitData) { 57 | if (createInitErr) { 58 | console.error('Was unable to initialize creation of composite resource ' + 59 | 'substack representation: %j', createInitErr) 60 | return reply('Composite substack create init error:' + (createInitErr.message || 'UNKNOWN_FATAL')) 61 | } 62 | console.log('Successfully initialized create of substack representation ' + 63 | 'of the composite resource: %j', createInitData) 64 | reply(null, createInitData.StackId, {}) 65 | }) 66 | }) 67 | } 68 | function Update(physicalId, params, oldParams, reply) { 69 | var ComposeInstance = new Composer() 70 | console.log('Entering composite resource UPDATE action...') 71 | console.log('Running composite resource substack ' + 72 | 'composition function with params: %j', params) 73 | RunComposition(params, ComposeInstance, function(composeErr) { 74 | if (composeErr) { 75 | console.error('Error while composing composite resource' + 76 | 'substack representation for UPDATE on %s: %j', physicalId, composeErr) 77 | return reply('FATAL substack composition error: ' + 78 | (composeErr.message || 'UNKNOWN_FATAL')) 79 | } 80 | var stackParams = { 81 | StackName: physicalId, 82 | Capabilities: [ 83 | 'CAPABILITY_IAM' 84 | ], 85 | TemplateBody: ComposeInstance.Result(), 86 | UsePreviousTemplate: false 87 | } 88 | console.log('Updating substack representation of ' + 89 | 'resource %s with: %j', physicalId, stackParams) 90 | CFN.updateStack(stackParams, function(updateInitErr, updateInitData) { 91 | if (updateInitErr) { 92 | console.error('Was unable to initialize update of composite resource ' + 93 | 'substack representation: %j', updateInitErr) 94 | return reply('Composite substack update init error:' + (updateInitErr.message || 'UNKNOWN_FATAL')) 95 | } 96 | console.log('Successfully initialized update of substack representation ' + 97 | 'of the composite resource: %j', updateInitData) 98 | reply(null, updateInitData.StackId, {}) 99 | }) 100 | }) 101 | } 102 | function Delete(physicalId, params, reply) { 103 | console.log('Entering composite resource DELETE action...') 104 | var stackParams = { 105 | StackName: physicalId 106 | } 107 | console.log('Deleting substack representation of ' + 108 | 'resource %s with: %j', physicalId, stackParams) 109 | CFN.deleteStack(stackParams, function(deleteInitErr, deleteInitData) { 110 | if (deleteInitErr && deleteInitErr.statusCode !== 404) { 111 | console.error('Was unable to initialize delete of composite resource ' + 112 | 'substack representation: %j', deleteInitErr) 113 | return reply('Composite substack delete init error:' + (deleteInitErr.message || 'UNKNOWN_FATAL')) 114 | } 115 | console.log('Successfully initialized delete of substack representation ' + 116 | 'of the composite resource: %j', deleteInitData) 117 | reply(null, physicalId, {}) 118 | }) 119 | } 120 | function CheckCreate(createRes, params, reply, notDone) { 121 | var physicalId = createRes.PhysicalResourceId 122 | console.log('Entering CheckCreate for the composite resource, ' + 123 | 'checking substack representation outputs for: %s', physicalId) 124 | CFN.describeStacks({ 125 | StackName: physicalId 126 | }, function(getStackErr, stackData) { 127 | if (getStackErr) { 128 | console.error('During composite resource CheckCreate op on %s, ' + 129 | 'unable to pull context Outputs: %j', physicalId, getStackErr) 130 | return reply('FATAL: could not pull context: ' + 131 | (getStackErr.message || 'TOTAL_FAILURE')) 132 | } 133 | if (!stackData.Stacks.length) { 134 | console.error('During composite resource CheckCreate op on %s, ' + 135 | 'unable to pull context Outputs: (Not Found!)', physicalId) 136 | return reply('Could not find the composite resource stack.') 137 | } 138 | var Stack = stackData.Stacks[0] 139 | var StackStatus = Stack.StackStatus 140 | switch (StackStatus) { 141 | case 'CREATE_IN_PROGRESS': 142 | return notDone() 143 | case 'CREATE_FAILED': 144 | case 'CREATE_COMPLETE': 145 | return succeed() 146 | case 'ROLLBACK_IN_PROGRESS': 147 | case 'ROLLBACK_FAILED': 148 | case 'ROLLBACK_COMPLETE': 149 | case 'DELETE_IN_PROGRESS': 150 | case 'DELETE_FAILED': 151 | case 'DELETE_COMPLETE': 152 | case 'UPDATE_IN_PROGRESS': 153 | case 'UPDATE_COMPLETE_CLEANUP_IN_PROGRESS': 154 | case 'UPDATE_COMPLETE': 155 | case 'UPDATE_ROLLBACK_IN_PROGRESS': 156 | case 'UPDATE_ROLLBACK_FAILED': 157 | case 'UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS': 158 | case 'UPDATE_ROLLBACK_COMPLETE': 159 | return failure() 160 | } 161 | function failure() { 162 | console.error('Composite resource substack %s found ' + 163 | 'to be broken during CheckCreate: %j', Stack) 164 | reply('Composite resource substack broken, in STATE => ' + StackStatus) 165 | } 166 | function succeed() { 167 | var outputHashFormatted = toGetAttFormat(Stack.Outputs) 168 | console.log('Successfully acquired composite resource ' + 169 | 'substack %s output hash during CheckCreate: %j', physicalId, outputHashFormatted) 170 | reply(null, physicalId, outputHashFormatted) 171 | } 172 | }) 173 | } 174 | function CheckUpdate(updateRes, physicalId, params, oldParams, reply, notDone) { 175 | console.log('Entering CheckUpdate for the composite resource, ' + 176 | 'checking substack representation outputs for: %s', physicalId) 177 | CFN.describeStacks({ 178 | StackName: physicalId 179 | }, function(getStackErr, stackData) { 180 | if (getStackErr) { 181 | console.error('During composite resource CheckCreate op on %s, ' + 182 | 'unable to pull context Outputs: %j', physicalId, getStackErr) 183 | return reply('FATAL: could not pull context: ' + 184 | (getStackErr.message || 'TOTAL_FAILURE')) 185 | } 186 | if (!stackData.Stacks.length) { 187 | console.error('During composite resource CheckCreate op on %s, ' + 188 | 'unable to pull context Outputs: (Not Found!)', physicalId) 189 | return reply('Could not find the composite resource stack.') 190 | } 191 | var Stack = stackData.Stacks[0] 192 | var StackStatus = Stack.StackStatus 193 | switch (StackStatus) { 194 | case 'CREATE_IN_PROGRESS': 195 | case 'CREATE_FAILED': 196 | return failure() 197 | case 'CREATE_COMPLETE': 198 | case 'ROLLBACK_IN_PROGRESS': 199 | return notDone() 200 | case 'ROLLBACK_FAILED': 201 | case 'ROLLBACK_COMPLETE': 202 | return failure() 203 | case 'DELETE_IN_PROGRESS': 204 | return notDone() 205 | case 'DELETE_FAILED': 206 | case 'DELETE_COMPLETE': 207 | return failure() 208 | case 'UPDATE_IN_PROGRESS': 209 | case 'UPDATE_COMPLETE_CLEANUP_IN_PROGRESS': 210 | return notDone() 211 | case 'UPDATE_COMPLETE': 212 | return succeed() 213 | case 'UPDATE_ROLLBACK_IN_PROGRESS': 214 | return notDone() 215 | case 'UPDATE_ROLLBACK_FAILED': 216 | return failure() 217 | case 'UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS': 218 | return notDone() 219 | case 'UPDATE_ROLLBACK_COMPLETE': 220 | return failure() 221 | } 222 | function failure() { 223 | console.error('Composite resource substack %s found ' + 224 | 'to be broken during CheckUpdate: %j', Stack) 225 | reply('Composite resource substack broken, in STATE => ' + StackStatus) 226 | } 227 | function succeed() { 228 | var outputHashFormatted = toGetAttFormat(Stack.Outputs) 229 | console.log('Successfully acquired composite resource ' + 230 | 'substack %s output hash during CheckUpdate: %j', physicalId, outputHashFormatted) 231 | reply(null, physicalId, outputHashFormatted) 232 | } 233 | }) 234 | } 235 | function CheckDelete(deleteRes, physicalId, params, reply, notDone) { 236 | console.log('Entering CheckDelete for the composite resource, ' + 237 | 'checking substack representation outputs for: %s', physicalId) 238 | CFN.describeStacks({ 239 | StackName: physicalId 240 | }, function(getStackErr, stackData) { 241 | if (getStackErr && ( 242 | getStackErr.statusCode === 404 || 243 | getStackErr.message === 'Stack with id ' + physicalId + ' does not exist')) { 244 | console.log('During composite resource CheckDelete op on %s, ' + 245 | 'unable to find Stack, implicit delete, succeeding: %j', 246 | physicalId, getStackErr) 247 | return reply(null, physicalId) 248 | } 249 | if (getStackErr) { 250 | console.error('During composite resource CheckDelete op on %s, ' + 251 | 'unable to pull context: %j', physicalId, getStackErr) 252 | return reply('FATAL: could not pull context: ' + 253 | (getStackErr.message || 'TOTAL_FAILURE')) 254 | } 255 | if (!stackData.Stacks.length) { 256 | console.error('During composite resource CheckDelete op on %s, ' + 257 | 'unable to pull context Outputs: (Not Found!)', physicalId) 258 | return reply('Could not find the composite resource stack.') 259 | } 260 | var Stack = stackData.Stacks[0] 261 | var StackStatus = Stack.StackStatus 262 | switch (StackStatus) { 263 | case 'CREATE_IN_PROGRESS': 264 | return notDone() 265 | case 'CREATE_FAILED': 266 | case 'CREATE_COMPLETE': 267 | return failure() 268 | case 'ROLLBACK_IN_PROGRESS': 269 | return notDone() 270 | case 'ROLLBACK_FAILED': 271 | return failure() 272 | case 'ROLLBACK_COMPLETE': 273 | return succeed() 274 | case 'DELETE_IN_PROGRESS': 275 | return notDone() 276 | case 'DELETE_FAILED': 277 | return failure() 278 | case 'DELETE_COMPLETE': 279 | return succeed() 280 | case 'UPDATE_IN_PROGRESS': 281 | case 'UPDATE_COMPLETE_CLEANUP_IN_PROGRESS': 282 | return notDone() 283 | case 'UPDATE_COMPLETE': 284 | return failure() 285 | case 'UPDATE_ROLLBACK_IN_PROGRESS': 286 | return notDone() 287 | case 'UPDATE_ROLLBACK_FAILED': 288 | case 'UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS': 289 | return notDone() 290 | case 'UPDATE_ROLLBACK_COMPLETE': 291 | return failure() 292 | } 293 | function failure() { 294 | console.error('Composite resource substack %s found ' + 295 | 'to be broken during CheckDelete: %j', Stack) 296 | reply('Composite resource substack broken, in STATE => ' + StackStatus) 297 | } 298 | function succeed() { 299 | console.log('Successfully found destroyed ' + 300 | 'substack %s during CheckDelete.', physicalId) 301 | reply(null, physicalId) 302 | } 303 | }) 304 | } 305 | function serviceToken(mod) { 306 | return [ 307 | "arn", 308 | "aws", 309 | "lambda", 310 | CfnLambda.Environment.Region, 311 | CfnLambda.Environment.AccountId, 312 | "function", 313 | (mod.Name + '-' + mod.Version.replace(/\./g, '-')) 314 | ].join(':') 315 | } 316 | function Composer() { 317 | var Template = { 318 | AWSTemplateFormatVersion: "2010-09-09", 319 | Description: "A dynamic cfn-lambda Composite resource substack.", 320 | Resources: {}, 321 | Outputs: {} 322 | } 323 | function AddResource(ns, logicalId, params, deps) { 324 | Template.Resources[logicalId] = 'string' === typeof ns 325 | ? makeResource(ns, params, deps) 326 | : customResource(ns, params, deps) 327 | } 328 | function AddOutput(logicalName, value) { 329 | Template.Outputs[logicalName] = { 330 | Description: 'Composite resource substack output for: ' + logicalName, 331 | Value: value 332 | } 333 | } 334 | function Result() { 335 | return JSON.stringify(Template) 336 | } 337 | return { 338 | AddResource: AddResource, 339 | AddOutput: AddOutput, 340 | Result: Result 341 | } 342 | } 343 | 344 | function customResource(mod, params, deps) { 345 | var Properties = clone(params) 346 | var ResourceType = 'Custom::' + mod.Name.split('-').map(function(piece) { 347 | return piece[0].toUpperCase() + piece.slice(1, piece.length) 348 | }).join('') 349 | Properties.ServiceToken = serviceToken(mod) 350 | return makeResource(ResourceType, Properties, deps) 351 | } 352 | 353 | function makeResource(type, params, deps) { 354 | var resource = { 355 | Type: type, 356 | Properties: params 357 | } 358 | if (Array.isArray(deps) && deps.length) { 359 | resource.DependsOn = deps 360 | } 361 | return resource 362 | } 363 | 364 | function clone(obj) { 365 | return JSON.parse(JSON.stringify(obj)) 366 | } 367 | 368 | function toGetAttFormat(outputs) { 369 | return (outputs || []).reduce(function(hash, output) { 370 | hash[output.OutputKey] = output.OutputValue 371 | return hash 372 | }, {}) 373 | } 374 | 375 | 376 | return CfnLambda({ 377 | NoUpdate, 378 | Create, 379 | Update, 380 | Delete, 381 | LongRunning: { 382 | PingInSeconds, 383 | MaxPings, 384 | LambdaApi: new AWS.Lambda(), 385 | Methods: { 386 | Create: CheckCreate, 387 | Update: CheckUpdate, 388 | Delete: CheckDelete 389 | } 390 | } 391 | }) 392 | } 393 | 394 | function Module(name) { 395 | var modulePackageContent = JSON.parse(fs 396 | .readFileSync(path.resolve(__dirname, 397 | '..', '..', name, 'package.json')).toString()) 398 | return { 399 | Name: name, 400 | Version: modulePackageContent.version 401 | } 402 | } 403 | 404 | 405 | 406 | Composite.Module = Module 407 | 408 | module.exports = Composite 409 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # cfn-lambda 3 | 4 | 5 | ** Please open a PR for any issue you open in which you suggest a patch - I do not have time to maintain this by myself anymore, and it generally works as advertised so any improvements requested are incumbent upon the requester to help implement, thanks / with love <3 ** 6 | 7 | ## Purpose 8 | 9 | A simple flow for generating CloudFormation Lambda-Backed Custom Resource handlers in node.js. The scope of this module is to structure the way developers author simple Lambda-Backed resources into simple functional definitions of `Create`, `Update`, `Delete`. 10 | 11 | Also supports: 12 | - Extremely simple deployments 13 | - Automatic creation of CloudFormation Quick Launch links, so you can easily share your open-source Custom Resources in any region! 14 | - Automatic expansion of `__default__` `Properties` values into any tree or subtree of any Custom Resource utilizing `cfn-lambda` for implementation 15 | - Validation of `'ResourceProperties'` 16 | + Using inline JSONSchema objects as `Schema` 17 | + Using a `SchemaPath` to JSONSchema file 18 | + Using a custom `Validate` callback 19 | - Optional `NoUpdate` callback, which runs as a READ function for when `Update` should be made due to all parameters being identical - because some resources still need to return attributes for `Fn::GetAtt` calls. 20 | - Convenience `Environment` values 21 | + Lambda ARN 22 | + Lambda Name 23 | + AWS Account ID for the Lambda 24 | + Region for the Lambda 25 | - Array of String `TriggersReplacement` for `Resource.Properties` key strings that force delegation to resource `Create` for seamless full replacement without downtime in many cases, and forcing `UPDATE_COMPLETE_CLEANUP_IN_PROGRESS`. 26 | - An `SDKAlias` function generator that structures and greatly simplifies the development of custom resources that are supported by the Node.js `aws-sdk` but not supported by CloudFormation. 27 | 28 | 29 | [This package on NPM](https://www.npmjs.com/package/cfn-lambda) 30 | [This package on GitHub](https://www.github.com/andrew-templeton/cfn-lambda) 31 | 32 | ## Custom Resources To Try 33 | 34 | Since version 2.0.0 of this tool, this supports Launch Pages, which are a simple way to share your resources. These are some you can try. 35 | 36 | - Add Amazon Lex Slot Types as a supported CloudFormation resource [here](https://s3.amazonaws.com/cfn-lex-slot-type-006297545748-us-east-1/1-0-1.html) 37 | - More added soon! Just click a Launch link to install into your account. 38 | 39 | ## Launch Pages 40 | 41 | Once you build a resource with this tool, if you use the `--public` setting, you can share these resources by sharing your Launch Pages. These are HTML pages hosted in S3 that the tool automatically creates during deployments. They're only accessible to the public if you specifically set `--public` during a deployment. You should only do this for open source custom resource types. 42 | 43 | These pages are accessible in all regions in which you deploy resource types to. They generally follow this link pattern: `/--/.html`. 44 | 45 | If you're confused, check for the HTML pages that are inserted into the S3 buckets deployed by this tool during deploys. 46 | 47 | 48 | ## Source Code Examples 49 | 50 | The "old" resources below work, they just are now supported by built-in CloudFormation resource types. They still are good examples of how to implement. 51 | 52 | - *Stable* `Custom::LexSlotType` ([GitHub](https://github.com/andrew-templeton/cfn-lex-slot-type) / [NPM](https://www.npmjs.com/package/cfn-lex-slot-type)) 53 | - *Stable, old* `Custom::ApiGatewayRestApi` ([GitHub](https://github.com/andrew-templeton/cfn-api-gateway-restapi) / [NPM](https://www.npmjs.com/package/cfn-api-gateway-restapi)) 54 | - *Stable, old* `Custom::ApiGatewayMethod` ([GitHub](https://github.com/andrew-templeton/cfn-api-gateway-method) / [NPM](https://www.npmjs.com/package/cfn-api-gateway-method)) 55 | - *Stable, old* `Custom::ApiGatewayMethodResponse` ([GitHub](https://github.com/andrew-templeton/cfn-api-gateway-method-response) / [NPM](https://www.npmjs.com/package/cfn-api-gateway-method-response)) 56 | - *Stable, old, uses LongRunning configurations correctly* `Custom::ElasticSearchServiceDomain` ([GitHub](https://github.com/andrew-templeton/cfn-elasticsearch-domain) / [NPM](https://www.npmjs.com/package/cfn-elasticsearch-domain)) 57 | 58 | ## Call For Contributions 59 | 60 | Hey you! Are you an AWS automation engineer? I'd love if you'd author open-source resources with this tool. Just submit a PR to this page for a *specific tag* on your repository, and I'll review it and add it to the page. 61 | 62 | Furthermore, if you want to help style the generated HTML in the launcher pages, I'd love help with that too :) 63 | 64 | Feel free to tweet me about involvement too: [@ayetempleton](https://twitter.com/ayetempleton) thanks! 65 | 66 | ## Deployment of Lambdas 67 | 68 | Any custom resource using this tool as a dependency can run deploy scripts from the root of the custom resource project to deploy Lambdas to all regions. 69 | 70 | To do this most simply, add this line to the `"scripts"` section of your `package.json` inside your repository using this module as a direct dependency: 71 | 72 | "deploy": "node ./node_modules/cfn-lambda/deploy.js --allregions --logs" 73 | 74 | This will deploy your custom resource to *all regions*. If you want to customize this behavior, use the options below. These options also apply to using the `deploy.js` script, as well. 75 | 76 | You can also deploy the Lambdas programmatically from JS by importing the module: `require('cfn-lambda')`. The same options that work on the command line below work as values on an option hash: `require('cfn-lambda')(options, callback)`. 77 | 78 | You must also set up: 79 | 80 | 1. Add `/execution-policy.json` to define the abilities the Lambda should have. 81 | 2. Have AWS credentials configured in your environment, via one of: 82 | + `$AWS_PROFILE` in your environment 83 | + a credentials file 84 | + `$AWS_ACCESS_KEY_ID` and `$AWS_SECRET_ACCESS_KEY` in your environment. 85 | 86 | You then run this from within the repository directly depending on `cfn-lambda` (your custom resource implementation using this package): 87 | 88 | $ npm run deploy 89 | 90 | 91 | Again, this will, if you used the suggested `package.json` edit to use the `deploy.js` inside this repo, deploy your custom resource implementation to all regions using some default settings. Please read the below options, as you may want to restrict deployment to only a couple regions. You might want to do this if your custom resource uses AWS services only available in a smaller subset of regions than Lambda is available in. 92 | 93 | Since this uses CloudFormation to install, you can get the ServiceToken usable in the region to begin creating resources from the Outputs and Exports of the generated stack at the `ServiceToken` key. The stack by default launches with name `-`. You can manually grab the value from the `Outputs` in that template, and use it later, or you can use `Fn::ImportValue` to directly get it in any stack. 94 | 95 | 96 | "Fn::ImportValue": "--ServiceToken" 97 | 98 | 99 | This value may change if you use the `--alias` or `--version` flags below, since the Stack name will be different. 100 | 101 | 102 | ### Options 103 | 104 | When using the module in JS, a simple hash is passed in as the first argument. When on the command line, boolean parameters are set to `true` with `--`. Parameters needing a value are set with `-- `. 105 | 106 | ###### `account` 107 | 108 | Used to specify the AWS Account ID to launch the systems into. By default, is the account associated with the Role or User currently invoking the script. Useful when a cross-account role is being used. 109 | 110 | ###### `alias` 111 | 112 | Instead of deploying the systems with this naming pattern: `-`, it replaces the package name: `-`. 113 | 114 | ###### `allregions` 115 | 116 | Causes your custom resource to be deployed on all regions supporting AWS Lambda. `false` by default. 117 | 118 | ###### `logs` 119 | 120 | Makes the deployment system log to STDOUT. Defaults to false when used via JS as a module, and defaults to `true` on CLI. You can turn CLI logging off with the `--quiet` option. 121 | 122 | ###### `module` 123 | 124 | Sets the tool to deploy the custom service in the module you provide, relative to the current working directory. For example, if this package, and your package using `cfn-lambda` are both dependencies of a project, from that project's root, set `--module `. The `--path` argument takes precedence if both `--module` and `--path` are defined. 125 | 126 | ###### `path` 127 | 128 | The tool will deploy the `cfn-lambda`-based custom resource you have defined to use the provided path. Best used when `cfn-lambda` is not in your project's `node_modules` directory. If you do not provide `--path` or `--module`, the system assumes that `cfn-lambda` is in the `node_modules` directory of your project, and thus uses this `--path`: `cfn-lambda/../../` (assumes you're using `cfn-lambda` as a normal `node_modules` dependency of your custom resource project directory). 129 | 130 | ###### `public` 131 | 132 | Sets the Quick Launch Page to be publicly accessible, as well as the Lambda function code zip bundle for your custom resource type. Defaults to `false`. 133 | 134 | Setting this to `true` if you are an open source software author will allow anyone to install your custom resources without needing to run this installation script, just by clicking a link on the browser, in the HTML page this tool generates for you in your S3 buckets in each region you have this script run on. Bear in mind, that you will be responsible for the AWS fees associated with others accessing your bucket. 135 | 136 | ###### `quiet` 137 | 138 | Forces logs off, on both CLI and with JS module usage. Takes precedence over `--logs`, so use this to make your CLI invocations stop producing logs. Defaults to `false`. 139 | 140 | ###### `regions` 141 | 142 | Sets the AWS regions to deploy your custom resource type to. Defaults to the value of `$AWS_REGION` in your environment, or none, if you do not set `$AWS_REGION`. 143 | 144 | This value is ignored if you set `--allregions`. 145 | 146 | On the CLI, values are passed in comma-delimited, with no spaces, like `us-east-1,us-east-2`. When using the module via JS, pass this value in as a plain JavaScript array. 147 | 148 | ###### `rollback` 149 | 150 | Setting this value to `false` prevents the CloudFormation stacks this tool uses to deploy from rolling back when any errors occur during initial creation. On the CLI, the value must be exactly `false`. With module-style usage in JS, any falsey value will achieve the same effect. Defaults to `true`, thus allowing any stacks with failures during creation to roll back. 151 | 152 | ###### `version` 153 | 154 | Instead of deploying the systems with this naming pattern: `-`, it replaces the package version: `-`. Technically, it does not have to be a number, but using the format `x-y-z` is strongly suggested. 155 | 156 | 157 | ## Usage 158 | 159 | This is a contrived example call to fully demonstrate the way to interface with the creation API. 160 | 161 | You can manually define these properties, or use `SDKAlias` for `Create`, `Update` and/or `Delete`. 162 | 163 | 164 | ### Resource Lambda Generation 165 | ``` 166 | var CfnLambda = require('cfn-lambda'); 167 | 168 | 169 | exports.handler = CfnLambda({ 170 | 171 | Create: Create, // Required function 172 | Update: Update, // Required function 173 | Delete: Delete, // Required function 174 | 175 | // Any of following to validate resource Properties 176 | // If you do not include any, the Lambda assumes any Properties are valid. 177 | // If you define more than one, the system uses all of them in this order. 178 | Validate: Validate, // Function 179 | Schema: Schema, // JSONSchema v4 Object 180 | SchemaPath: SchemaPath, // Array path to JSONSchema v4 JSON file 181 | // end list 182 | 183 | NoUpdate: NoUpdate, // Optional 184 | TriggersReplacement: TriggersReplacement, // Array of properties forcing Replacement 185 | 186 | LongRunning: // Optional. Configure a lambda to last beyond 5 minutes. 187 | 188 | }); 189 | ``` 190 | 191 | ### `Environment` Convenience Property 192 | 193 | Provides convenience `Environment` values.: 194 | 195 | var CfnLambda = require('cfn-lambda'); 196 | // After receiving `event` and `context`... 197 | console.log(CfnLambda.Environment); 198 | /* 199 | { 200 | `LambdaArn`: 'foo bar', // Full ARN for the current Lambda 201 | `Region`: 'us-east-1', // Region in which current Lambda resides 202 | `AccountId`: '012345678910', // The account associated with the Lambda 203 | `LambdaName`: 'LambdaName' // Name for the current Lambda 204 | } 205 | */ 206 | 207 | 208 | *Only works after the generated `CfnLambda` function has been called by Lambda.* 209 | 210 | 211 | #### `Create` Method Handler 212 | 213 | Called when CloudFormation issues a `'CREATE'` command. 214 | Accepts the `CfnRequestParams` Properties object, and the `reply` callback. 215 | 216 | ``` 217 | function Create(CfnRequestParams, reply) { 218 | // code... 219 | if (err) { 220 | // Will fail the create. 221 | // err should be informative for Cfn template developer. 222 | return reply(err); 223 | } 224 | // Will pass the create. 225 | // physicalResourceId defaults to the request's `[StackId, LogicalResourceId, RequestId].join('/')`. 226 | // FnGetAttrsDataObj is optional. 227 | reply(null, physicalResourceId, FnGetAttrsDataObj); 228 | } 229 | ``` 230 | #### `Update` Method Handler 231 | 232 | Called when CloudFormation issues an `'UPDATE'` command. 233 | Accepts the `RequestPhysicalId` `String`, `CfnRequestParams` Properties object, the `OldCfnRequestParams` Properties object, and the `reply` callback. 234 | 235 | ``` 236 | function Update(RequestPhysicalID, CfnRequestParams, OldCfnRequestParams, reply) { 237 | // code... 238 | if (err) { 239 | // Will fail the update. 240 | // err should be informative for Cfn template developer. 241 | return reply(err); 242 | } 243 | // Will pass the update. 244 | // physicalResourceId defaults to pre-update value. 245 | // FnGetAttrsDataObj is optional. 246 | reply(null, physicalResourceId, FnGetAttrsDataObj); 247 | } 248 | ``` 249 | 250 | #### `Delete` Method Handler 251 | 252 | Called when CloudFormation issues a `'DELETE'` command. 253 | Accepts the `RequestPhysicalId` `String`, `CfnRequestParams` Properties object, and the `reply` callback. 254 | 255 | ``` 256 | function Delete(RequestPhysicalID, CfnRequestParams, reply) { 257 | // code... 258 | if (err) { 259 | // Will fail the delete (or rollback). 260 | // USE CAUTION - failing aggressively will lock template, 261 | // because DELETE is used during ROLLBACK phases. 262 | // err should be informative for Cfn template developer. 263 | return reply(err); 264 | } 265 | // Will pass the delete. 266 | // physicalResourceId defaults to pre-delete value. 267 | // FnGetAttrsDataObj is optional. 268 | reply(null, physicalResourceId, FnGetAttrsDataObj); 269 | } 270 | ``` 271 | 272 | ### Async function support 273 | 274 | If your handler function is [async](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function), then your custom lambda resource may unexpectedly exit before your Promise(s) have resolved. The result of this is your CloudFormation stack hanging for up to an hour which is very undesirable behavior. You can read more about the underlying issue in this great [medium article](https://medium.com/radient-tech-blog/aws-lambda-and-the-node-js-event-loop-864e48fba49). To fix this, you can use the async counterparts of the given handler type: 275 | 276 | ``` 277 | Create -> AsyncCreate 278 | Update -> AsyncUpdate 279 | Delete -> AsyncDelete 280 | NoUpdate -> AsyncNoUpdate 281 | ``` 282 | 283 | This difference with the async counterparts is that there is no reply() callback. Instead, your function should return a Promise (or be marked `async`) which resolves with an object containing the `PhysicalResourceId` and `FnGetAttrsDataObj` outputs. See below example: 284 | 285 | ``` 286 | const wait = () => { 287 | return new Promise((resolve, reject) => { 288 | setTimeout(() => { 289 | resolve(); 290 | }, 2000); 291 | }); 292 | }; 293 | const createHandler = async (cfnRequestParams) => { 294 | await wait(); 295 | return { 296 | PhysicalResourceId: "yopadope", 297 | FnGetAttrsDataObj: { 298 | MyObj: "dopeayope" 299 | } 300 | }; 301 | }; 302 | exports.handler = cfnLambda({ 303 | AsyncCreate: createHandler, 304 | ... 305 | }); 306 | ``` 307 | 308 | ### Validating Properties 309 | 310 | Used before `'CREATE'`, `'UPDATE'`, or `'DELETE'` method handlers. The CloudFormation request will automatically fail if any truthy values are returned, and any `String` values returned are displayed to the template developer, to assist with resource `Properties` object correction. 311 | 312 | *Important:* To prevent `ROLLBACK` lockage, the `'DELETE'` will be short circuited if this check fails. If this check fails, CloudFormation will be told that everything went fine, but no actual further actions will occur. This is because CloudFormation will immediately issue a `'DELETE'` after a failure in a `'CREATE'` or an `'UPDATE'`. Since these failures themselves will have resulted from a validation method failure if the subsequent `'DELETE'` fails, this is safe. 313 | 314 | May be a: 315 | - Custom validation function as `Validate` callback 316 | - JSONSchema v4 `Schema` 317 | - JSONSchema v4 file path as `SchemaPath` 318 | 319 | #### `Validate` Method Handler 320 | 321 | The truthy `String` return value will cause a `'FAILURE'`, and the `String` value is used as the CloudFormation `'REASON'`. 322 | 323 | ``` 324 | // Example using a custom function 325 | // CfnRequestParams are all resource `Properties`, 326 | // except for the required system `ServiceToken`. 327 | function Validate(CfnRequestParams) { 328 | // code... 329 | if (unmetParamCondition) { 330 | return 'You must blah blah include a parameter... etc' 331 | } 332 | if (someOtherCondition) { 333 | return 'Informative message to CFN template developer goes here.' 334 | } 335 | // Returning a falsey value will allow the action to proceed. 336 | // DO NOT return truthy if the request params are valid. 337 | } 338 | ``` 339 | 340 | #### `Schema` Object - JSONSchema Version 4 341 | 342 | Using a JSONSchema `Schema` property value will automatically generate the `String` invalidation return values for you when validating against the parameters - simply provide the template and the validation and error messging is taken care of for you. 343 | 344 | If you choose to use a JSONSchema template, the service will also use the JSONSchema metaschema to ensure the provided JSONSchema is a valid schema itself. 345 | 346 | ``` 347 | // Example using a custom JSONSchema Version 4 template 348 | // This might be in a file you manually load like `schema.json`, or a JS object. 349 | var Schema = { 350 | type: 'object', 351 | required: [ 352 | 'foo' 353 | ], 354 | properties: { 355 | foo: { 356 | type: 'string' 357 | }, 358 | selectable: { 359 | type: 'string', 360 | enum: ['list', 'of', 'valid', 'values'] 361 | } 362 | }, 363 | additionalProperties: false 364 | }; 365 | ``` 366 | 367 | #### `SchemaPath` Array - Path to JSONSchema Version 4 File 368 | 369 | A convenient way to get the benefits of `Schema` object validation, but keeping your code clean and segregated nicely. 370 | 371 | The path is defined as an Array so that we can use the `path` module. 372 | 373 | ``` 374 | var SchemaPath = [__dirname, 'src', 'mytemplate.json']; 375 | ``` 376 | 377 | 378 | #### `NoUpdate` Method Handler 379 | 380 | Optional. Triggered by deep JSON object equality of the old and new parameters, if defined. 381 | 382 | Even when short-circuiting an `Update` is a good idea, a resource provider may still need to return a set of properties for use with `Fn::GetAtt` in CloudFormation templates. This `NoUpdate` handler triggers in the special case where no settings on the resource change, allowing the developer to simultaneously skip manipulation logic while doing read operations on resources to generate the attribute sets `Fn::GetAtt` will need. 383 | 384 | ``` 385 | // Using a custom NoUpdate for READ to supply properties 386 | // for Fn::GetAtt to access in CloudFormation templates 387 | function NoUpdate(PhysicalResourceId, CfnResourceProperties, reply) { 388 | // code that should be read-only if you're sane... 389 | if (errorAccessingInformation) { 390 | return reply('with an informative message'); 391 | } 392 | // Can have many keys on the object, though I only show one here 393 | reply(null, PhysicalResourceId, {Accessible: 'Attrs object in CFN template'}); 394 | } 395 | ``` 396 | 397 | 398 | ## Long Running 399 | 400 | *This is very advanced Lambda self replication.* 401 | 402 | The inner workings of this feature are a lot to take in. I strongly suggest you just read the source code for `cfn-elasticsearch-domain` to see how the `index.js` file utilizes the `LongRunning` feature, as the concrete example code is much more understandable than abstract definitions of parameters and options. 403 | 404 | `cfn-elasticsearch-domain/index.js` [GitHub](https://www.github.com/andrew-templeton/cfn-elasticsearch-domain/blob/master/index.js) 405 | 406 | *If you have the appetite for it... Read on...* 407 | 408 | Some resources will take a considerable amount of time to complete, like an Elasticsearch Domain. In order to utilize Lambda-Backed Custom Resources within CloudFormation while avoiding the hard 300 second / 5 minute Lambda timeout for resources that will take more than 5 minutes to finish, `cfn-lambda` allows resource developers to leverage bundled Lambda self-replication logic. Developers can configure the `LongRunning` property on the lambda definition options object with a few settings to tell `cfn-lambda` to simply run some action initialization code (such as initiating an Elasticsearch Domain Create), then periodically self-replicate to check the status of the long-running process. The majority of cases where AWS APIs or SDKs return `statusCode === 202` will use this technique to avoid Lambda death at 5 minutes. 409 | 410 | The self-replication strategy will trigger if the developer configures the following on the LongRunning property object: `PingInSeconds`, `MaxPings`, `LambdaApi`, `Methods.METHOD_NAME`. 411 | 412 | ##### PingInSeconds 413 | 414 | The duration a Lambda will wait between spawning self-replication calls and triggering the next `LongRunning.Methods.METHOD_NAME` call. This value should not exceed `240` (4 minutes), because we need to leave enough time before the 5 minute hard process death is triggered by AWS. 415 | 416 | After this time, the lambda will spawn a new lambda, which will call the `LongRunning.Method.METHOD_NAME`, where `METHOD_NAME` is `Create`, `Delete`, `Update`, depending on which are configured and the lifecycle phase the resource is moving through. 417 | 418 | ##### MaxPings 419 | 420 | The maximum number of self-respawn and check cycles the Lambda will go through. After exceeding this number, the Lambda will circuit break and send a `Failed to Stabilize` response to the CloudFormation stack. 421 | 422 | ##### LambdaApi 423 | 424 | `cfn-lambda` uses this namespace to invoke the Lambda. Allows the Custom Resource developer using `cfn-lambda` to specify a Lambda API version, or stub the value out for testing. 425 | 426 | In most cases, just pass `new AWS.Lambda({apiVersion: '2015-03-31'})` as the API namespace. 427 | 428 | ##### Methods 429 | 430 | Most of the `LongRunning` logic happens here. At its most configured, this subobject will have 3 properties corresponding to the normal actions: `Create`, `Update`, and `Delete`. 431 | 432 | When you configure one of these properties, the flow of that CloudFormation action type changes - within the `reply` callback function in the corresponding normal/top-level callback you defined for the resource, `reply`-ing with success just tells `cfn-lambda` that you correctly initialized the `Create`/`Delete`/`Update` for the resource, and to start using the corresponding `LongRunning.Methods.METHOD` to ping to final completion. That is, the resource will not `COMPLETE` the action until the function you define finalizes the `SUCCESS`. 433 | 434 | Read below to see how to define each `LongRunning.Methods.METHOD`... 435 | 436 | #### `LongRunningContext` Param Object 437 | 438 | All three `LongRunning.Methods` receive a special object as their first parameter. The `LongRunningContext` object carries useful state across all spawned lambda ping cycles. 439 | 440 | - `LongRunningContext.RawResponse`: carries the original intercepted call that your first Create initialization call tried to send to CloudFormation. Used internally for state manipulation. DO NOT ALTER THIS VALUE unless you *really* know what you're doing, as tampering can cause Lambda recursion to spiral out of control! 441 | - `LongRunningContext.PhysicalResourceId`: Carries the original `PhysicalResourceId` intercepted call that your first Create initialization call tried to send to CloudFormation. Useful if your check functions need this value and cannot recompute it from the `ResourceProperties` sent by CloudFormation. 442 | - `LongRunningContext.Data`: Carries the original data hash, if present, intercepted from your call to `reply` within the initializer method. Useful if your check functions need these data value(s) and cannot recompute them from the `ResourceProperties` sent by CloudFormation. Will not be present if you did not pass a third parameter to `reply` in the initializer, since the `GetAtt`-usable `Data` hash is optional in `cfn-lambda`. 443 | - `LongRunningContext.PassedPings`: The number of ping spawns before this current run that have occurred. DO NOT ALTER THIS NUMBER! Subtracting from this number will make your Lambdas infinitely self-replicate, *very very bad*! 444 | 445 | 446 | #### `LongRunning.Methods.Create` 447 | 448 | Will be called during Lambda pingspawn cycles. Here, `CheckCreate` is an example of a check function definition for `LongRunning.Methods.Create`. 449 | 450 | ``` 451 | function CheckCreate(LongRunningContext, params, reply, notDone) { 452 | // LongRunningContext is object type specified above 453 | // params are Properties straight from CloudFomation 454 | // reply is callback just like in normal Create, 455 | // call it with reply(errMsg) or reply(null, physicalId, AttrHash) 456 | // notDone takes no parameters, use this to tell 457 | // cfn-lambda to use another ping/spawn cycle and check again later 458 | } 459 | ``` 460 | 461 | #### `LongRunning.Methods.Update` 462 | 463 | Will be called during Lambda pingspawn cycles. Here, `CheckUpdate` is an example of a check function definition for `LongRunning.Methods.Update`. 464 | 465 | ``` 466 | function CheckUpdate(LongRunningContext, physcialId, params, oldParams, reply, notDone) { 467 | // LongRunningContext is object type specified above 468 | // physicalId is PhysicalResourceId from pre-Update resource state 469 | // params are Properties straight from CloudFomation 470 | // oldParams are Properties from CloudFormation for before the Update began 471 | // reply is callback just like in normal Update, 472 | // call it with reply(errMsg) or reply(null, physicalId, AttrHash) 473 | // to finalize the transition and notify CloudFormation. 474 | // notDone takes no parameters, use this to denote no errors and tell 475 | // cfn-lambda to use another ping/spawn cycle and check again later 476 | } 477 | ``` 478 | 479 | #### `LongRunning.Methods.Delete` 480 | 481 | Will be called during Lambda pingspawn cycles. Here, `CheckDelete` is an example of a check function definition for `LongRunning.Methods.Delete`. 482 | 483 | ``` 484 | function CheckDelete(LongRunningContext, physcialId, params, reply, notDone) { 485 | // LongRunningContext is object type specified above 486 | // physicalId is PhysicalResourceId from pre-Delete resource state 487 | // params are Properties straight from CloudFomation 488 | // reply is callback just like in normal Delete, 489 | // call it with reply(errMsg) or reply(null, physicalId, AttrHash) 490 | // to finalize the transition and notify CloudFormation. 491 | // notDone takes no parameters, use this to denote no errors and tell 492 | // cfn-lambda to use another ping/spawn cycle and check again later 493 | } 494 | ``` 495 | 496 | 497 | ## `TriggersReplacement` Array 498 | 499 | Optional. Tells `cfn-lambda` to divert the `'Update'` call from CloudFormation to the `Create` handler the developer assigns to the Lambda. This technique results in the most seamless resource replacement possible, by causing the new resource to be created before the old one is deleted. This `Delete` cleanup process occurs in the `UPDATE_COMPLETE_CLEANUP_IN_PROGRESS` phase after all new resources are created. This property facilitates triggering that said phase. 500 | 501 | ``` 502 | exports.handler = CfnLambda({ 503 | // other properties 504 | TriggersReplacement: ['Foo', 'Bar'], 505 | // other properties 506 | }); 507 | ``` 508 | 509 | Now, if the Lambda above ever detects a change in the value of `Foo` or `Bar` resource Properties on `Update`, the Lambda will delegate to a two-phase `Create`-new-then-`Delete`-old resource replacement cycle. It will use the `Create` handler provided to the same `CfnLambda`, then subsequently the prodvided `Delete` if and only if the `Create` handler sends a `PhysicalResourceId` different from the original to the `reply` callback in the handler. 510 | 511 | 512 | ## `SDKAlias` Function Generator 513 | 514 | Structures and accelerates development of resources supported by the `aws-sdk` (or your custom SDK) by offering declarative tools to ingest events and proxy them to AWS services. 515 | 516 | Will automatically correctly ignore `ServiceToken` from CloudFormation Properties. All settings are optional, except `api` and `method`. 517 | 518 | ##### Usage Reference 519 | ``` 520 | var AWS = require('aws-sdk'); 521 | var AnAWSApi = new AWS.SomeNamespace(); 522 | var CfnLambda = require('cfn-lambda'); 523 | // Then used as the Create property as defined in Usage above 524 | var MyAliasActionName = CfnLambda.SDKAlias({ // Like Create, Update, Delete 525 | returnPhysicalId: 'KeyFromSDKReturn' || function(data) { return 'customValue'; }, 526 | downcase: boolean, // Downcase first letter of all top-level params from CloudFormation 527 | api: AnAWSApi, // REQUIRED 528 | method: 'methodNameInSDK', // REQUIRED 529 | mapKeys: { 530 | KeyNameInCfn: 'KeyNameForSDK' 531 | }, 532 | forceBools: [ // CloudFormation doesn't allow Lambdas to recieve true booleans. 533 | 'PathToCfnPropertyParam', // This will coerce the parameter at this path. 534 | 'Also.Supports.Wildcards.*', 535 | 'But', 536 | 'only.at.path.end' 537 | ], 538 | keys: [ // Defaults to including ALL keys from CloudFormation, minus ServiceToken 539 | 'KeysFrom', 540 | 'CloudFormationProperties', 541 | 'ToPassTo', 542 | 'TheSDKMethod', 543 | '**UsedBeforeMapKeys**' 544 | ], 545 | returnAttrs: [ 546 | 'KeysFrom', 547 | 'SDKReturnValue', 548 | 'ToUseWithCfn', 549 | 'Fn::GetAttr', 550 | 'You.Can.Access.Nested.Properties.As.Well' 551 | ], 552 | ignoreErrorCodes: [IntegerCodeToIgnore, ExWouldBe404ForDeleteOps], 553 | physicalIdAs: 'UsePhysicalIdAsThisKeyInSDKCall', 554 | // physicalIdAs: 'OrUseNested.Property.Using.Dot.Notation' 555 | }); 556 | 557 | // Then... 558 | 559 | exports.handler = CfnLambda({ 560 | Create: MyAliasActionName, // Doesn't have to be Create, can be Update or Delete 561 | // ... 562 | }); 563 | ``` 564 | 565 | ## Defaults 566 | 567 | Sometimes it is advantageous to be able to reuse JSON objects or fragments of JSON objects in `Properties` of Custom Resources, like when you need to build similar complex/large resources frequently that differ by only a few properties. 568 | 569 | Any module using `cfn-lambda` supports `__default__` property expansion. `__default__` can be added anywhere in the `Properties` object for a resource, with `__default__` containing an arbitrary `JSON/String/Array/null/Number` value serialized using `toBase64(JSON.stringify(anyObject))`. `cfn-lambda` will expand these properties *before* hitting any validation checks, by running `JSON.parse(fromBase64(encodedDefault))` recursively, and overwriting any values in the `__default__` tree with those actually set on the `Properties` object. 570 | 571 | The best example of this is the `cfn-variable` module's `example.template.json`, wherein a very large `RestApi` is created with over a large repeated subtree of `Resource` objects. `cfn-variable` is a custom resource that takes any value and serializes it using `toBase64(JSON.stringify(anyValue))`, making it a perfect fit for this behavior. 572 | 573 | In the example in `cfn-variable`, this technique is used to create 120 `Resource` objects in under 15 seconds (this example uses less): 574 | ``` 575 | // This is cfn-variable storing the serialized object: 576 | "MySubtreeVariable": { 577 | "Type": "Custom::Variable", 578 | "Properties": { 579 | "ServiceToken": { 580 | "Fn::Join": [ 581 | ":", 582 | [ 583 | "arn", 584 | "aws", 585 | "lambda", 586 | { 587 | "Ref": "AWS::Region" 588 | }, 589 | { 590 | "Ref": "AWS::AccountId" 591 | }, 592 | "function", 593 | { 594 | "Ref": "VariableCustomResourceName" 595 | } 596 | ] 597 | ] 598 | }, 599 | "VariableValue": { 600 | "ChildResources": [ 601 | { 602 | "PathPart": "a", 603 | "ChildResources": [ 604 | { 605 | "PathPart": "aa", 606 | "ChildResources": [ 607 | { 608 | "PathPart": "aaa" 609 | }, 610 | { 611 | "PathPart": "aab" 612 | }, 613 | { 614 | "PathPart": "aac" 615 | } 616 | ] 617 | }, 618 | { 619 | "PathPart": "ab", 620 | "ChildResources": [ 621 | { 622 | "PathPart": "aba" 623 | }, 624 | { 625 | "PathPart": "abb" 626 | }, 627 | { 628 | "PathPart": "abc" 629 | } 630 | ] 631 | }, 632 | { 633 | "PathPart": "ac", 634 | "ChildResources": [ 635 | { 636 | "PathPart": "aca" 637 | }, 638 | { 639 | "PathPart": "acb" 640 | }, 641 | { 642 | "PathPart": "acc" 643 | } 644 | ] 645 | } 646 | ] 647 | } 648 | ] 649 | } 650 | } 651 | }, 652 | // Then this will make the tree 3x because you used a variable with __default__ 653 | "ExpandedResourceTree": { 654 | "DependsOn": [ 655 | "MyRestApi", 656 | "MyVariable" 657 | ], 658 | "Type": "Custom::ApiGatewayResourceTree", 659 | "Properties": { 660 | "ServiceToken": "", 661 | "RestApiId": { 662 | "Ref": "MyRestApi" 663 | }, 664 | "ParentId": { 665 | "Fn::GetAtt": [ 666 | "MyRestApi", 667 | "RootResourceId" 668 | ] 669 | }, 670 | "ChildResources": [ 671 | { 672 | "PathPart": "alpha", 673 | "__default__": { 674 | "Fn::GetAtt": [ 675 | "MySubtreeVariable", 676 | "Value" 677 | ] 678 | } 679 | }, 680 | { 681 | "PathPart": "beta", 682 | "__default__": { 683 | "Fn::GetAtt": [ 684 | "MySubtreeVariable", 685 | "Value" 686 | ] 687 | } 688 | }, 689 | { 690 | "PathPart": "gamma", 691 | "__default__": { 692 | "Fn::GetAtt": [ 693 | "MySubtreeVariable", 694 | "Value" 695 | ] 696 | } 697 | } 698 | ] 699 | } 700 | } 701 | ``` 702 | --------------------------------------------------------------------------------