diff --git a/package.json b/package.json index 6ef00495be0b1..eab001af307d7 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,8 @@ "@aws-cdk/cdk-assets-schema/semver/**", "@aws-cdk/core/minimatch", "@aws-cdk/core/minimatch/**", + "@aws-cdk/cloudformation-include/yaml", + "@aws-cdk/cloudformation-include/yaml/**", "@aws-cdk/aws-codepipeline-actions/case", "@aws-cdk/aws-codepipeline-actions/case/**", "@aws-cdk/aws-ecr-assets/minimatch", diff --git a/packages/@aws-cdk/cloudformation-include/.eslintrc.js b/packages/@aws-cdk/cloudformation-include/.eslintrc.js new file mode 100644 index 0000000000000..1b28bad193ceb --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/.eslintrc.js @@ -0,0 +1,2 @@ +const baseConfig = require('../../../tools/cdk-build-tools/config/eslintrc'); +module.exports = baseConfig; diff --git a/packages/@aws-cdk/cloudformation-include/.gitignore b/packages/@aws-cdk/cloudformation-include/.gitignore new file mode 100644 index 0000000000000..e7a258fddd1bb --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/.gitignore @@ -0,0 +1,21 @@ +*.js +tslint.json +*.js.map +*.d.ts +*.generated.ts +dist +lib/generated/resources.ts +*.tgz +.jsii +tsconfig.json + +.LAST_BUILD +.nyc_output +coverage +nyc.config.js +.LAST_PACKAGE +*.snk + +!.eslintrc.js +!build.js +cfn-types-2-classes.json diff --git a/packages/@aws-cdk/cloudformation-include/.npmignore b/packages/@aws-cdk/cloudformation-include/.npmignore new file mode 100644 index 0000000000000..368728e2ee024 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/.npmignore @@ -0,0 +1,21 @@ +# Don't include original .ts files when doing `npm pack` +*.ts +!*.d.ts +coverage +.nyc_output +*.tgz + +dist +.LAST_PACKAGE +.LAST_BUILD +!*.js +.eslintrc.js + +# Include .jsii +!.jsii + +*.snk + +*.tsbuildinfo + +tsconfig.json diff --git a/packages/@aws-cdk/cloudformation-include/LICENSE b/packages/@aws-cdk/cloudformation-include/LICENSE new file mode 100644 index 0000000000000..b71ec1688783a --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018-2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/@aws-cdk/cloudformation-include/NOTICE b/packages/@aws-cdk/cloudformation-include/NOTICE new file mode 100644 index 0000000000000..bfccac9a7f69c --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/NOTICE @@ -0,0 +1,2 @@ +AWS Cloud Development Kit (AWS CDK) +Copyright 2018-2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/packages/@aws-cdk/cloudformation-include/README.md b/packages/@aws-cdk/cloudformation-include/README.md new file mode 100644 index 0000000000000..d4f2795b7d6d6 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/README.md @@ -0,0 +1,133 @@ +# Include CloudFormation templates in the CDK + + +--- + +![cdk-constructs: Experimental](https://img.shields.io/badge/cdk--constructs-experimental-important.svg?style=for-the-badge) + +> The APIs of higher level constructs in this module are experimental and under active development. They are subject to non-backward compatible changes or removal in any future version. These are not subject to the [Semantic Versioning](https://semver.org/) model and breaking changes will be announced in the release notes. This means that while you may use them, you may need to update your source code when upgrading to a newer version of this package. + +--- + + +This module contains a set of classes whose goal is to facilitate working +with existing CloudFormation templates in the CDK. +It can be thought of as an extension of the capabilities of the +[`CfnInclude` class](../@aws-cdk/core/lib/cfn-include.ts). + +## Basic usage + +Assume we have a file `my-template.json`, that contains the following CloudFormation template: + +```json +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "some-bucket-name" + } + } + } +} +``` + +It can be included in a CDK application with the following code: + +```typescript +import * as cfn_inc from '@aws-cdk/cloudformation-include'; + +const cfnTemplate = new cfn_inc.CfnInclude(this, 'Template', { + templateFile: 'my-template.json', +}); +``` + +This will add all resources from `my-template.json` into the CDK application, +preserving their original logical IDs from the template file. + +Any resource from the included template can be retrieved by referring to it by its logical ID from the template. +If you know the class of the CDK object that corresponds to that resource, +you can cast the returned object to the correct type: + +```typescript +import * as s3 from '@aws-cdk/aws-s3'; + +const cfnBucket = cfnTemplate.getResource('Bucket') as s3.CfnBucket; +// cfnBucket is of type s3.CfnBucket +``` + +Any modifications made to that resource will be reflected in the resulting CDK template; +for example, the name of the bucket can be changed: + +```typescript +cfnBucket.bucketName = 'my-bucket-name'; +``` + +You can also refer to the resource when defining other constructs, +including the higher-level ones +(those whose name does not start with `Cfn`), +for example: + +```typescript +import * as iam from '@aws-cdk/aws-iam'; + +const role = new iam.Role(this, 'Role', { + assumedBy: new iam.AnyPrincipal(), +}); +role.addToPolicy(new iam.PolicyStatement({ + actions: ['s3:*'], + resources: [cfnBucket.attrArn], +})); +``` + +If you need, you can also convert the CloudFormation resource to a higher-level +resource by importing it by its name: + +```typescript +const bucket = s3.Bucket.fromBucketName(this, 'L2Bucket', cfnBucket.ref); +// bucket is of type s3.IBucket +``` + +## Known limitations + +This module is still in its early, experimental stage, +and so does not implement all features of CloudFormation templates. +All items unchecked below are currently not supported. + +### Ability to retrieve CloudFormation objects from the template: + +- [x] Resources +- [ ] Parameters +- [ ] Conditions +- [ ] Outputs + +### [Resource attributes](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-product-attribute-reference.html): + +- [x] Properties +- [ ] Condition +- [ ] DependsOn +- [ ] CreationPolicy +- [ ] UpdatePolicy +- [x] UpdateReplacePolicy +- [x] DeletionPolicy +- [x] Metadata + +### [CloudFormation functions](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html): + +- [x] Ref +- [x] Fn::GetAtt +- [x] Fn::Join +- [x] Fn::If +- [ ] Fn::And +- [ ] Fn::Equals +- [ ] Fn::Not +- [ ] Fn::Or +- [ ] Fn::Base64 +- [ ] Fn::Cidr +- [ ] Fn::FindInMap +- [ ] Fn::GetAZs +- [ ] Fn::ImportValue +- [ ] Fn::Select +- [ ] Fn::Split +- [ ] Fn::Sub +- [ ] Fn::Transform diff --git a/packages/@aws-cdk/cloudformation-include/build.js b/packages/@aws-cdk/cloudformation-include/build.js new file mode 100644 index 0000000000000..ab36ffd345d7f --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/build.js @@ -0,0 +1,103 @@ +/** + * This build file has two purposes: + * 1. It adds a dependency on each @aws-cdk/aws-xyz package with L1s to this package, + * similarly to how deps.js does for decdk. + * 2. It generates the file cfn-types-2-classes.json that contains a mapping + * between the CloudFormation type and the fully-qualified name of the L1 class, + * used in the logic of the CfnInclude class. + */ + +const fs = require('fs'); +const path = require('path'); + +const jsii_reflect = require('jsii-reflect'); + +const packageJson = require('./package.json'); +const dependencies = packageJson.dependencies || {}; +const peerDependencies = packageJson.peerDependencies || {}; + +async function main() { + const constructLibrariesRoot = path.resolve('..'); + const constructLibrariesDirs = fs.readdirSync(constructLibrariesRoot); + let errors = false; + + const typeSystem = new jsii_reflect.TypeSystem(); + const cfnType2L1Class = {}; + // load the @aws-cdk/core assembly first, to find the CfnResource class + await typeSystem.load(path.resolve(constructLibrariesRoot, 'core'), { validate: false }); + const cfnResourceClass = typeSystem.findClass('@aws-cdk/core.CfnResource'); + + for (const constructLibraryDir of constructLibrariesDirs) { + const absConstructLibraryDir = path.resolve(constructLibrariesRoot, constructLibraryDir); + const libraryPackageJson = require(path.join(absConstructLibraryDir, 'package.json')); + + const libraryDependencyVersion = dependencies[libraryPackageJson.name]; + if (libraryPackageJson.maturity === 'deprecated') { + if (libraryDependencyVersion) { + console.error(`Incorrect dependency on deprecated package: ${libraryPackageJson.name}`); + errors = true; + delete dependencies[libraryPackageJson.name]; + delete peerDependencies[libraryPackageJson.name]; + } + // we don't want dependencies on deprecated modules, + // even if they do contain L1s (like eks-legacy) + continue; + } + + // we're not interested in modules that don't use cfn2ts + // (as they don't contain any L1s) + const cfn2ts = (libraryPackageJson['cdk-build'] || {}).cloudformation; + if (!cfn2ts) { + continue; + } + + const libraryVersion = libraryPackageJson.version; + if (!libraryDependencyVersion) { + console.error(`Missing dependency on package: ${libraryPackageJson.name}`); + errors = true; + } else if (libraryDependencyVersion !== libraryVersion) { + console.error(`Incorrect dependency version for package ${libraryPackageJson.name}: expecting '${libraryVersion}', got: '${libraryDependencyVersion}'`); + errors = true; + } + + dependencies[libraryPackageJson.name] = libraryVersion; + // dependencies need to be in both sections to satisfy pkglint + peerDependencies[libraryPackageJson.name] = libraryVersion; + + // load the assembly of this package, + // and find all subclasses of CfnResource to put them in cfnType2L1Class + const assembly = await typeSystem.load(absConstructLibraryDir, { validate: false }); + for (let i = 0; i < assembly.classes.length; i++) { + const classs = assembly.classes[i]; + if (classs.extends(cfnResourceClass)) { + const properties = classs.spec.properties; + const cfnResourceTypeNameProp = (properties || []).find(p => p.name === 'CFN_RESOURCE_TYPE_NAME'); + if (cfnResourceTypeNameProp) { + const [moduleName, ...className] = classs.fqn.split('.'); + const module = require(moduleName); + const jsClassFromModule = module[className.join('.')]; + cfnType2L1Class[jsClassFromModule.CFN_RESOURCE_TYPE_NAME] = classs.fqn; + } + } + } + } + + fs.writeFileSync(path.join(__dirname, 'package.json'), + JSON.stringify(packageJson, undefined, 2) + '\n'); + fs.writeFileSync(path.join(__dirname, 'cfn-types-2-classes.json'), + JSON.stringify(cfnType2L1Class, undefined, 2) + '\n'); + + if (errors) { + console.error('errors found. updated package.json'); + process.exit(1); + } +} + +(async () => { + try { + await main(); + } catch (e) { + console.error(e); + process.exit(1); + } +})(); diff --git a/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts new file mode 100644 index 0000000000000..9807e652dbdaf --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts @@ -0,0 +1,116 @@ +import * as core from '@aws-cdk/core'; +import * as cfn_type_to_l1_mapping from './cfn-type-to-l1-mapping'; +import * as futils from './file-utils'; + +/** + * Construction properties of {@link CfnInclude}. + */ +export interface CfnIncludeProps { + /** + * Path to the template file. + * + * Currently, only JSON templates are supported. + */ + readonly templateFile: string; +} + +/** + * Construct to import an existing CloudFormation template file into a CDK application. + * All resources defined in the template file can be retrieved by calling the {@link getResource} method. + * Any modifications made on the returned resource objects will be reflected in the resulting CDK template. + */ +export class CfnInclude extends core.CfnElement { + private readonly resources: { [logicalId: string]: core.CfnResource } = {}; + private readonly template: any; + private readonly preserveLogicalIds: boolean; + + constructor(scope: core.Construct, id: string, props: CfnIncludeProps) { + super(scope, id); + + // read the template into a JS object + this.template = futils.readJsonSync(props.templateFile); + + // ToDo implement preserveLogicalIds=false + this.preserveLogicalIds = true; + + // instantiate all resources as CDK L1 objects + for (const logicalId of Object.keys(this.template.Resources || {})) { + this.getOrCreateResource(logicalId); + } + } + + /** + * Returns the low-level CfnResource from the template with the given logical ID. + * Any modifications performed on that resource will be reflected in the resulting CDK template. + * + * The returned object will be of the proper underlying class; + * you can always cast it to the correct type in your code: + * + * // assume the template contains an AWS::S3::Bucket with logical ID 'Bucket' + * const cfnBucket = cfnTemplate.getResource('Bucket') as s3.CfnBucket; + * // cfnBucket is of type s3.CfnBucket + * + * If the template does not contain a resource with the given logical ID, + * an exception will be thrown. + * + * @param logicalId the logical ID of the resource in the CloudFormation template file + */ + public getResource(logicalId: string): core.CfnResource { + const ret = this.resources[logicalId]; + if (!ret) { + throw new Error(`Resource with logical ID '${logicalId}' was not found in the template`); + } + return ret; + } + + /** @internal */ + public _toCloudFormation(): object { + const ret: { [section: string]: any } = {}; + + for (const section of Object.keys(this.template)) { + // render all sections of the template unchanged, + // except Resources, which will be taken care of by the created L1s + if (section !== 'Resources') { + ret[section] = this.template[section]; + } + } + + return ret; + } + + private getOrCreateResource(logicalId: string): core.CfnResource { + const ret = this.resources[logicalId]; + if (ret) { + return ret; + } + + const resourceAttributes: any = this.template.Resources[logicalId]; + const l1ClassFqn = cfn_type_to_l1_mapping.lookup(resourceAttributes.Type); + if (!l1ClassFqn) { + // currently, we only handle types we know the L1 for - + // in the future, we might construct an instance of CfnResource instead + throw new Error(`Unrecognized CloudFormation resource type: '${resourceAttributes.Type}'`); + } + // fail early for resource attributes we don't support yet + const knownAttributes = ['Type', 'Properties', 'DeletionPolicy', 'UpdateReplacePolicy', 'Metadata']; + for (const attribute of Object.keys(resourceAttributes)) { + if (!knownAttributes.includes(attribute)) { + throw new Error(`The ${attribute} resource attribute is not supported by cloudformation-include yet. ` + + 'Either remove it from the template, or use the CdkInclude class from the core package instead.'); + } + } + + const [moduleName, ...className] = l1ClassFqn.split('.'); + const module = require(moduleName); // eslint-disable-line @typescript-eslint/no-require-imports + const jsClassFromModule = module[className.join('.')]; + const l1Instance = jsClassFromModule.fromCloudFormation(this, logicalId, resourceAttributes); + + if (this.preserveLogicalIds) { + // override the logical ID to match the original template + l1Instance.overrideLogicalId(logicalId); + } + + this.resources[logicalId] = l1Instance; + return l1Instance; + } +} diff --git a/packages/@aws-cdk/cloudformation-include/lib/cfn-type-to-l1-mapping.ts b/packages/@aws-cdk/cloudformation-include/lib/cfn-type-to-l1-mapping.ts new file mode 100644 index 0000000000000..07bb5e61d63c0 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/lib/cfn-type-to-l1-mapping.ts @@ -0,0 +1,25 @@ +import * as path from 'path'; +import * as futils from './file-utils'; + +let cfnTypeToL1Mapping: { [type: string]: string }; + +/** + * Returns the fully-qualified name + * (that is, including the NPM package name) + * of a class that corresponds to this CloudFormation type, + * or undefined if the given type was not found. + * + * For example, lookup("AWS::S3::Bucket") + * returns "@aws-cdk/aws-s3.CfnBucket". + */ +export function lookup(cfnType: string): string | undefined { + if (!cfnTypeToL1Mapping) { + cfnTypeToL1Mapping = loadCfnTypeToL1Mapping(); + } + + return cfnTypeToL1Mapping[cfnType]; +} + +function loadCfnTypeToL1Mapping(): any { + return futils.readJsonSync(path.join(__dirname, '..', 'cfn-types-2-classes.json')); +} diff --git a/packages/@aws-cdk/cloudformation-include/lib/file-utils.ts b/packages/@aws-cdk/cloudformation-include/lib/file-utils.ts new file mode 100644 index 0000000000000..aff2d3255f842 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/lib/file-utils.ts @@ -0,0 +1,6 @@ +import * as fs from 'fs'; + +export function readJsonSync(filePath: string): any { + const fileContents = fs.readFileSync(filePath); + return JSON.parse(fileContents.toString()); +} diff --git a/packages/@aws-cdk/cloudformation-include/lib/index.ts b/packages/@aws-cdk/cloudformation-include/lib/index.ts new file mode 100644 index 0000000000000..4a16f02a2f228 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/lib/index.ts @@ -0,0 +1 @@ +export * from './cfn-include'; diff --git a/packages/@aws-cdk/cloudformation-include/package.json b/packages/@aws-cdk/cloudformation-include/package.json new file mode 100644 index 0000000000000..b57847936c79e --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/package.json @@ -0,0 +1,350 @@ +{ + "name": "@aws-cdk/cloudformation-include", + "version": "0.0.0", + "description": "A package that facilitates working with existing CloudFormation templates in the CDK", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "jsii": { + "outdir": "dist", + "targets": { + "java": { + "package": "software.amazon.awscdk.cloudformation.include", + "maven": { + "groupId": "software.amazon.awscdk", + "artifactId": "cdk-cloudformation-include" + } + }, + "dotnet": { + "namespace": "Amazon.CDK.CloudFormation.Include", + "packageId": "Amazon.CDK.CloudFormation.Include", + "signAssembly": true, + "assemblyOriginatorKeyFile": "../../key.snk", + "iconUrl": "https://raw.githubusercontent.com/aws/aws-cdk/master/logo/default-256-dark.png" + }, + "python": { + "distName": "aws-cdk.cloudformation-include", + "module": "aws_cdk.cloudformation_include" + } + } + }, + "repository": { + "type": "git", + "url": "https://github.com/aws/aws-cdk.git", + "directory": "packages/@aws-cdk/cloudformation-include" + }, + "scripts": { + "build": "cdk-build", + "watch": "cdk-watch", + "lint": "cdk-lint", + "test": "cdk-test", + "pkglint": "pkglint -f", + "package": "cdk-package", + "awslint": "cdk-awslint", + "build+test": "npm run build && npm test", + "build+test+package": "npm run build+test && npm run package", + "compat": "cdk-compat" + }, + "cdk-build": { + "pre": [ + "node ./build.js" + ] + }, + "author": { + "name": "Amazon Web Services", + "url": "https://aws.amazon.com", + "organization": true + }, + "license": "Apache-2.0", + "dependencies": { + "@aws-cdk/alexa-ask": "0.0.0", + "@aws-cdk/aws-accessanalyzer": "0.0.0", + "@aws-cdk/aws-acmpca": "0.0.0", + "@aws-cdk/aws-amazonmq": "0.0.0", + "@aws-cdk/aws-amplify": "0.0.0", + "@aws-cdk/aws-apigateway": "0.0.0", + "@aws-cdk/aws-apigatewayv2": "0.0.0", + "@aws-cdk/aws-appconfig": "0.0.0", + "@aws-cdk/aws-applicationautoscaling": "0.0.0", + "@aws-cdk/aws-appmesh": "0.0.0", + "@aws-cdk/aws-appstream": "0.0.0", + "@aws-cdk/aws-appsync": "0.0.0", + "@aws-cdk/aws-athena": "0.0.0", + "@aws-cdk/aws-autoscaling": "0.0.0", + "@aws-cdk/aws-autoscalingplans": "0.0.0", + "@aws-cdk/aws-backup": "0.0.0", + "@aws-cdk/aws-batch": "0.0.0", + "@aws-cdk/aws-budgets": "0.0.0", + "@aws-cdk/aws-cassandra": "0.0.0", + "@aws-cdk/aws-ce": "0.0.0", + "@aws-cdk/aws-certificatemanager": "0.0.0", + "@aws-cdk/aws-chatbot": "0.0.0", + "@aws-cdk/aws-cloud9": "0.0.0", + "@aws-cdk/aws-cloudfront": "0.0.0", + "@aws-cdk/aws-cloudtrail": "0.0.0", + "@aws-cdk/aws-cloudwatch": "0.0.0", + "@aws-cdk/aws-codebuild": "0.0.0", + "@aws-cdk/aws-codecommit": "0.0.0", + "@aws-cdk/aws-codedeploy": "0.0.0", + "@aws-cdk/aws-codeguruprofiler": "0.0.0", + "@aws-cdk/aws-codepipeline": "0.0.0", + "@aws-cdk/aws-codestar": "0.0.0", + "@aws-cdk/aws-codestarconnections": "0.0.0", + "@aws-cdk/aws-codestarnotifications": "0.0.0", + "@aws-cdk/aws-cognito": "0.0.0", + "@aws-cdk/aws-config": "0.0.0", + "@aws-cdk/aws-datapipeline": "0.0.0", + "@aws-cdk/aws-dax": "0.0.0", + "@aws-cdk/aws-detective": "0.0.0", + "@aws-cdk/aws-directoryservice": "0.0.0", + "@aws-cdk/aws-dlm": "0.0.0", + "@aws-cdk/aws-dms": "0.0.0", + "@aws-cdk/aws-docdb": "0.0.0", + "@aws-cdk/aws-dynamodb": "0.0.0", + "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-ecr": "0.0.0", + "@aws-cdk/aws-ecs": "0.0.0", + "@aws-cdk/aws-efs": "0.0.0", + "@aws-cdk/aws-eks": "0.0.0", + "@aws-cdk/aws-elasticache": "0.0.0", + "@aws-cdk/aws-elasticbeanstalk": "0.0.0", + "@aws-cdk/aws-elasticloadbalancing": "0.0.0", + "@aws-cdk/aws-elasticloadbalancingv2": "0.0.0", + "@aws-cdk/aws-elasticsearch": "0.0.0", + "@aws-cdk/aws-emr": "0.0.0", + "@aws-cdk/aws-events": "0.0.0", + "@aws-cdk/aws-eventschemas": "0.0.0", + "@aws-cdk/aws-fms": "0.0.0", + "@aws-cdk/aws-fsx": "0.0.0", + "@aws-cdk/aws-gamelift": "0.0.0", + "@aws-cdk/aws-glue": "0.0.0", + "@aws-cdk/aws-greengrass": "0.0.0", + "@aws-cdk/aws-guardduty": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-inspector": "0.0.0", + "@aws-cdk/aws-iot": "0.0.0", + "@aws-cdk/aws-iot1click": "0.0.0", + "@aws-cdk/aws-iotanalytics": "0.0.0", + "@aws-cdk/aws-iotevents": "0.0.0", + "@aws-cdk/aws-iotthingsgraph": "0.0.0", + "@aws-cdk/aws-kinesis": "0.0.0", + "@aws-cdk/aws-kinesisanalytics": "0.0.0", + "@aws-cdk/aws-kinesisfirehose": "0.0.0", + "@aws-cdk/aws-kms": "0.0.0", + "@aws-cdk/aws-lakeformation": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", + "@aws-cdk/aws-logs": "0.0.0", + "@aws-cdk/aws-managedblockchain": "0.0.0", + "@aws-cdk/aws-mediaconvert": "0.0.0", + "@aws-cdk/aws-medialive": "0.0.0", + "@aws-cdk/aws-mediastore": "0.0.0", + "@aws-cdk/aws-msk": "0.0.0", + "@aws-cdk/aws-neptune": "0.0.0", + "@aws-cdk/aws-networkmanager": "0.0.0", + "@aws-cdk/aws-opsworks": "0.0.0", + "@aws-cdk/aws-opsworkscm": "0.0.0", + "@aws-cdk/aws-pinpoint": "0.0.0", + "@aws-cdk/aws-pinpointemail": "0.0.0", + "@aws-cdk/aws-qldb": "0.0.0", + "@aws-cdk/aws-ram": "0.0.0", + "@aws-cdk/aws-rds": "0.0.0", + "@aws-cdk/aws-redshift": "0.0.0", + "@aws-cdk/aws-resourcegroups": "0.0.0", + "@aws-cdk/aws-robomaker": "0.0.0", + "@aws-cdk/aws-route53": "0.0.0", + "@aws-cdk/aws-route53resolver": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/aws-sagemaker": "0.0.0", + "@aws-cdk/aws-sam": "0.0.0", + "@aws-cdk/aws-sdb": "0.0.0", + "@aws-cdk/aws-secretsmanager": "0.0.0", + "@aws-cdk/aws-securityhub": "0.0.0", + "@aws-cdk/aws-servicecatalog": "0.0.0", + "@aws-cdk/aws-servicediscovery": "0.0.0", + "@aws-cdk/aws-ses": "0.0.0", + "@aws-cdk/aws-sns": "0.0.0", + "@aws-cdk/aws-sqs": "0.0.0", + "@aws-cdk/aws-ssm": "0.0.0", + "@aws-cdk/aws-stepfunctions": "0.0.0", + "@aws-cdk/aws-synthetics": "0.0.0", + "@aws-cdk/aws-transfer": "0.0.0", + "@aws-cdk/aws-waf": "0.0.0", + "@aws-cdk/aws-wafregional": "0.0.0", + "@aws-cdk/aws-wafv2": "0.0.0", + "@aws-cdk/aws-workspaces": "0.0.0", + "@aws-cdk/core": "0.0.0", + "yaml": "1.9.2" + }, + "peerDependencies": { + "@aws-cdk/alexa-ask": "0.0.0", + "@aws-cdk/aws-accessanalyzer": "0.0.0", + "@aws-cdk/aws-acmpca": "0.0.0", + "@aws-cdk/aws-amazonmq": "0.0.0", + "@aws-cdk/aws-amplify": "0.0.0", + "@aws-cdk/aws-apigateway": "0.0.0", + "@aws-cdk/aws-apigatewayv2": "0.0.0", + "@aws-cdk/aws-appconfig": "0.0.0", + "@aws-cdk/aws-applicationautoscaling": "0.0.0", + "@aws-cdk/aws-appmesh": "0.0.0", + "@aws-cdk/aws-appstream": "0.0.0", + "@aws-cdk/aws-appsync": "0.0.0", + "@aws-cdk/aws-athena": "0.0.0", + "@aws-cdk/aws-autoscaling": "0.0.0", + "@aws-cdk/aws-autoscalingplans": "0.0.0", + "@aws-cdk/aws-backup": "0.0.0", + "@aws-cdk/aws-batch": "0.0.0", + "@aws-cdk/aws-budgets": "0.0.0", + "@aws-cdk/aws-cassandra": "0.0.0", + "@aws-cdk/aws-ce": "0.0.0", + "@aws-cdk/aws-certificatemanager": "0.0.0", + "@aws-cdk/aws-chatbot": "0.0.0", + "@aws-cdk/aws-cloud9": "0.0.0", + "@aws-cdk/aws-cloudfront": "0.0.0", + "@aws-cdk/aws-cloudtrail": "0.0.0", + "@aws-cdk/aws-cloudwatch": "0.0.0", + "@aws-cdk/aws-codebuild": "0.0.0", + "@aws-cdk/aws-codecommit": "0.0.0", + "@aws-cdk/aws-codedeploy": "0.0.0", + "@aws-cdk/aws-codeguruprofiler": "0.0.0", + "@aws-cdk/aws-codepipeline": "0.0.0", + "@aws-cdk/aws-codestar": "0.0.0", + "@aws-cdk/aws-codestarconnections": "0.0.0", + "@aws-cdk/aws-codestarnotifications": "0.0.0", + "@aws-cdk/aws-cognito": "0.0.0", + "@aws-cdk/aws-config": "0.0.0", + "@aws-cdk/aws-datapipeline": "0.0.0", + "@aws-cdk/aws-dax": "0.0.0", + "@aws-cdk/aws-detective": "0.0.0", + "@aws-cdk/aws-directoryservice": "0.0.0", + "@aws-cdk/aws-dlm": "0.0.0", + "@aws-cdk/aws-dms": "0.0.0", + "@aws-cdk/aws-docdb": "0.0.0", + "@aws-cdk/aws-dynamodb": "0.0.0", + "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-ecr": "0.0.0", + "@aws-cdk/aws-ecs": "0.0.0", + "@aws-cdk/aws-efs": "0.0.0", + "@aws-cdk/aws-eks": "0.0.0", + "@aws-cdk/aws-elasticache": "0.0.0", + "@aws-cdk/aws-elasticbeanstalk": "0.0.0", + "@aws-cdk/aws-elasticloadbalancing": "0.0.0", + "@aws-cdk/aws-elasticloadbalancingv2": "0.0.0", + "@aws-cdk/aws-elasticsearch": "0.0.0", + "@aws-cdk/aws-emr": "0.0.0", + "@aws-cdk/aws-events": "0.0.0", + "@aws-cdk/aws-eventschemas": "0.0.0", + "@aws-cdk/aws-fms": "0.0.0", + "@aws-cdk/aws-fsx": "0.0.0", + "@aws-cdk/aws-gamelift": "0.0.0", + "@aws-cdk/aws-glue": "0.0.0", + "@aws-cdk/aws-greengrass": "0.0.0", + "@aws-cdk/aws-guardduty": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-inspector": "0.0.0", + "@aws-cdk/aws-iot": "0.0.0", + "@aws-cdk/aws-iot1click": "0.0.0", + "@aws-cdk/aws-iotanalytics": "0.0.0", + "@aws-cdk/aws-iotevents": "0.0.0", + "@aws-cdk/aws-iotthingsgraph": "0.0.0", + "@aws-cdk/aws-kinesis": "0.0.0", + "@aws-cdk/aws-kinesisanalytics": "0.0.0", + "@aws-cdk/aws-kinesisfirehose": "0.0.0", + "@aws-cdk/aws-kms": "0.0.0", + "@aws-cdk/aws-lakeformation": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", + "@aws-cdk/aws-logs": "0.0.0", + "@aws-cdk/aws-managedblockchain": "0.0.0", + "@aws-cdk/aws-mediaconvert": "0.0.0", + "@aws-cdk/aws-medialive": "0.0.0", + "@aws-cdk/aws-mediastore": "0.0.0", + "@aws-cdk/aws-msk": "0.0.0", + "@aws-cdk/aws-neptune": "0.0.0", + "@aws-cdk/aws-networkmanager": "0.0.0", + "@aws-cdk/aws-opsworks": "0.0.0", + "@aws-cdk/aws-opsworkscm": "0.0.0", + "@aws-cdk/aws-pinpoint": "0.0.0", + "@aws-cdk/aws-pinpointemail": "0.0.0", + "@aws-cdk/aws-qldb": "0.0.0", + "@aws-cdk/aws-ram": "0.0.0", + "@aws-cdk/aws-rds": "0.0.0", + "@aws-cdk/aws-redshift": "0.0.0", + "@aws-cdk/aws-resourcegroups": "0.0.0", + "@aws-cdk/aws-robomaker": "0.0.0", + "@aws-cdk/aws-route53": "0.0.0", + "@aws-cdk/aws-route53resolver": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/aws-sagemaker": "0.0.0", + "@aws-cdk/aws-sam": "0.0.0", + "@aws-cdk/aws-sdb": "0.0.0", + "@aws-cdk/aws-secretsmanager": "0.0.0", + "@aws-cdk/aws-securityhub": "0.0.0", + "@aws-cdk/aws-servicecatalog": "0.0.0", + "@aws-cdk/aws-servicediscovery": "0.0.0", + "@aws-cdk/aws-ses": "0.0.0", + "@aws-cdk/aws-sns": "0.0.0", + "@aws-cdk/aws-sqs": "0.0.0", + "@aws-cdk/aws-ssm": "0.0.0", + "@aws-cdk/aws-stepfunctions": "0.0.0", + "@aws-cdk/aws-synthetics": "0.0.0", + "@aws-cdk/aws-transfer": "0.0.0", + "@aws-cdk/aws-waf": "0.0.0", + "@aws-cdk/aws-wafregional": "0.0.0", + "@aws-cdk/aws-wafv2": "0.0.0", + "@aws-cdk/aws-workspaces": "0.0.0", + "@aws-cdk/core": "0.0.0", + "constructs": "^3.0.2" + }, + "devDependencies": { + "@aws-cdk/assert": "0.0.0", + "@types/jest": "^25.2.1", + "@types/yaml": "1.2.0", + "cdk-build-tools": "0.0.0", + "jest": "^25.4.0", + "pkglint": "0.0.0", + "ts-jest": "^25.4.0" + }, + "bundledDependencies": [ + "yaml" + ], + "jest": { + "moduleFileExtensions": [ + "js", + "ts" + ], + "collectCoverage": true, + "coverageThreshold": { + "global": { + "branches": 70, + "statements": 80 + } + }, + "coverageReporters": [ + "lcov", + "html", + "text-summary" + ], + "preset": "ts-jest", + "testMatch": [ + "**/?(*.)+(test).ts" + ] + }, + "keywords": [ + "aws", + "cdk", + "cloudformation", + "template", + "include", + "including", + "migration", + "migrating", + "migrate" + ], + "homepage": "https://github.com/aws/aws-cdk", + "engines": { + "node": ">= 10.13.0" + }, + "stability": "experimental", + "maturity": "experimental", + "awscdkio": { + "announce": false + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts b/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts new file mode 100644 index 0000000000000..955d6ff312d87 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts @@ -0,0 +1,53 @@ +import { SynthUtils } from '@aws-cdk/assert'; +import '@aws-cdk/assert/jest'; +import * as core from '@aws-cdk/core'; +import * as path from 'path'; +import * as inc from '../lib'; + +describe('CDK Include', () => { + let stack: core.Stack; + + beforeEach(() => { + stack = new core.Stack(); + }); + + test('throws a validation exception for a template with a missing required top-level resource property', () => { + expect(() => { + includeTestTemplate(stack, 'bucket-policy-without-bucket.json'); + }).toThrow(/missing required property: bucket/); + }); + + test('throws a validation exception for a template with a resource property expecting an array assigned the wrong type', () => { + includeTestTemplate(stack, 'bucket-with-cors-rules-not-an-array.json'); + + expect(() => { + SynthUtils.synthesize(stack); + }).toThrow(/corsRules: "CorsRules!" should be a list/); + }); + + test('throws a validation exception for a template with a null array element of a complex type with required fields', () => { + includeTestTemplate(stack, 'bucket-with-cors-rules-null-element.json'); + + expect(() => { + SynthUtils.synthesize(stack); + }).toThrow(/allowedMethods: required but missing/); + }); + + test('throws a validation exception for a template with a missing nested resource property', () => { + includeTestTemplate(stack, 'bucket-with-invalid-cors-rule.json'); + + expect(() => { + SynthUtils.synthesize(stack); + }).toThrow(/allowedOrigins: required but missing/); + }); +}); + +function includeTestTemplate(scope: core.Construct, testTemplate: string): inc.CfnInclude { + return new inc.CfnInclude(scope, 'MyScope', { + templateFile: _testTemplateFilePath(testTemplate), + }); +} + +function _testTemplateFilePath(testTemplate: string) { + return path.join(__dirname, 'test-templates', 'invalid', testTemplate); +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/bucket-with-encryption-key.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/bucket-with-encryption-key.json new file mode 100644 index 0000000000000..75bb7d0c72b54 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/bucket-with-encryption-key.json @@ -0,0 +1,75 @@ +{ + "Resources": { + "Key": { + "Type": "AWS::KMS::Key", + "Properties": { + "KeyPolicy": { + "Statement": [ + { + "Action": [ + "kms:*" + ], + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::", + { + "Ref": "AWS::AccountId" + }, + ":root" + ] + ] + } + }, + "Resource": "*" + } + ], + "Version": "2012-10-17" + } + }, + "DeletionPolicy": "Delete", + "UpdateReplacePolicy": "Delete" + }, + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "ServerSideEncryptionByDefault": { + "KMSMasterKeyID": { + "Fn::GetAtt": [ + "Key", + "Arn" + ] + }, + "SSEAlgorithm": "aws:kms" + } + } + ] + } + }, + "Metadata" : { + "Object1" : "Location1", + "KeyRef": { + "Ref": "Key" + }, + "KeyArn": { + "Fn::GetAtt": [ + "Key", + "Arn" + ] + } + }, + "DeletionPolicy": "Retain", + "UpdateReplacePolicy": "Retain" + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/if-complex-property.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/if-complex-property.json new file mode 100644 index 0000000000000..8a6e83480f782 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/if-complex-property.json @@ -0,0 +1,46 @@ +{ + "Conditions": { + "AlwaysFalseCond": { + "Fn::Equals": [ + { + "Ref": "AWS::Region" + }, + "completely-made-up-region" + ] + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "CorsConfiguration": { + "CorsRules": [ + { + "Fn::If": [ + "AlwaysFalseCond", + { + "AllowedMethods": [ + "GET" + ], + "AllowedOrigins": [ + "*" + ], + "MaxAge": 10 + }, + { + "AllowedMethods": [ + "POST" + ], + "AllowedOrigins": [ + "/path/*" + ], + "MaxAge": 20 + } + ] + } + ] + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/if-simple-property.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/if-simple-property.json new file mode 100644 index 0000000000000..0d833ec8031d1 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/if-simple-property.json @@ -0,0 +1,26 @@ +{ + "Conditions": { + "AlwaysFalseCond": { + "Fn::Equals": [ + { + "Ref": "AWS::Region" + }, + "completely-made-up-region" + ] + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::If": [ + "AlwaysFalseCond", + "Name1", + "Name2" + ] + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/bucket-policy-without-bucket.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/bucket-policy-without-bucket.json new file mode 100644 index 0000000000000..c665e5f2641b7 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/bucket-policy-without-bucket.json @@ -0,0 +1,33 @@ +{ + "Resources": { + "BucketPolicy": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "s3:GetObject", + "Effect": "Allow", + "Principal": "*", + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "Bucket2", + "Arn" + ] + }, + "/*" + ] + ] + } + } + ], + "Version": "2012-10-17" + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/bucket-with-cors-rules-not-an-array.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/bucket-with-cors-rules-not-an-array.json new file mode 100644 index 0000000000000..52bbe7131c709 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/bucket-with-cors-rules-not-an-array.json @@ -0,0 +1,12 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "CorsConfiguration": { + "CorsRules": "CorsRules!" + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/bucket-with-cors-rules-null-element.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/bucket-with-cors-rules-null-element.json new file mode 100644 index 0000000000000..e7d41a13d4360 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/bucket-with-cors-rules-null-element.json @@ -0,0 +1,14 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "CorsConfiguration": { + "CorsRules": [ + null + ] + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/bucket-with-invalid-cors-rule.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/bucket-with-invalid-cors-rule.json new file mode 100644 index 0000000000000..a58f9e7867c9c --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/bucket-with-invalid-cors-rule.json @@ -0,0 +1,16 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "CorsConfiguration": { + "CorsRules": [ + { + "AllowedMethods": [] + } + ] + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/non-existent-resource-type.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/non-existent-resource-type.json new file mode 100644 index 0000000000000..9307ef8976f2d --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/non-existent-resource-type.json @@ -0,0 +1,7 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::FakeService::DoesNotExist" + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/only-bucket-complex-props.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/only-bucket-complex-props.json new file mode 100644 index 0000000000000..afaa16a4ab17a --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/only-bucket-complex-props.json @@ -0,0 +1,22 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "CorsConfiguration": { + "CorsRules": [ + { + "AllowedMethods": [ + "GET" + ], + "AllowedOrigins": [ + "*" + ], + "MaxAge": 10 + } + ] + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/only-codecommit-repo-using-cfn-functions.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/only-codecommit-repo-using-cfn-functions.json new file mode 100644 index 0000000000000..a1037dbf2402a --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/only-codecommit-repo-using-cfn-functions.json @@ -0,0 +1,13 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::CodeCommit::Repository", + "Properties": { + "RepositoryName": "my-repository", + "RepositoryDescription": { + "Fn::Base64": "my description, in base-64!" + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/only-empty-bucket-with-parameters.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/only-empty-bucket-with-parameters.json new file mode 100644 index 0000000000000..66c92f4e4de41 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/only-empty-bucket-with-parameters.json @@ -0,0 +1,41 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "My template description", + "Parameters": { + "Param": { + "Description": "The description of the parameter", + "Type": "String", + "Default": "" + } + }, + "Conditions": { + "Cond1": { + "Fn::Equals": ["a", "b"] + } + }, + "Outputs": { + "Output1": { + "Value": { + "Fn::Base64": "Output1Value" + } + } + }, + "Metadata": { + "Instances" : { + "Description" : "Information about the instances" + } + }, + "Mappings" : { + "Mapping01" : { + "Key01" : { + "Name" : "Value01" + } + } + }, + "Transform": "AWS::Serverless-2016-10-31", + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket" + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/only-empty-bucket.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/only-empty-bucket.json new file mode 100644 index 0000000000000..ead9c6c0e35a6 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/only-empty-bucket.json @@ -0,0 +1,7 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket" + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/ref-array-property.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/ref-array-property.json new file mode 100644 index 0000000000000..4c0c277c382ae --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/ref-array-property.json @@ -0,0 +1,29 @@ +{ + "Parameters": { + "Methods": { + "Description": "The description of the parameter", + "Type": "CommaDelimitedList", + "Default": "GET,PUT" + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "CorsConfiguration": { + "CorsRules": [ + { + "AllowedMethods": { + "Ref": "Methods" + }, + "AllowedOrigins": [ + "/path/*" + ], + "MaxAge": 20 + } + ] + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/resource-attribute-condition.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/resource-attribute-condition.json new file mode 100644 index 0000000000000..77caf02cb0357 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/resource-attribute-condition.json @@ -0,0 +1,18 @@ +{ + "Conditions": { + "AlwaysFalseCond": { + "Fn::Equals": [ + { + "Ref": "AWS::Region" + }, + "completely-made-up-region" + ] + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Condition": "AlwaysFalseCond" + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/resource-attribute-creation-policy.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/resource-attribute-creation-policy.json new file mode 100644 index 0000000000000..c342227788535 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/resource-attribute-creation-policy.json @@ -0,0 +1,12 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "CreationPolicy": { + "AutoScalingCreationPolicy": { + "MinSuccessfulInstancesPercent": 50 + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/resource-attribute-depends-on.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/resource-attribute-depends-on.json new file mode 100644 index 0000000000000..82bd4fa42b847 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/resource-attribute-depends-on.json @@ -0,0 +1,11 @@ +{ + "Resources": { + "Bucket1": { + "Type": "AWS::S3::Bucket" + }, + "Bucket2": { + "Type": "AWS::S3::Bucket", + "DependsOn": "Bucket1" + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/resource-attribute-update-policy.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/resource-attribute-update-policy.json new file mode 100644 index 0000000000000..7032979006266 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/resource-attribute-update-policy.json @@ -0,0 +1,12 @@ +{ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "UpdatePolicy": { + "AutoScalingScheduledAction": { + "IgnoreUnmodifiedGroupSizeProperties": true + } + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts b/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts new file mode 100644 index 0000000000000..6bdd001bddb27 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts @@ -0,0 +1,257 @@ +import '@aws-cdk/assert/jest'; +import * as iam from '@aws-cdk/aws-iam'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as core from '@aws-cdk/core'; +import * as path from 'path'; +import * as inc from '../lib'; +import * as futils from '../lib/file-utils'; + +// tslint:disable:object-literal-key-quotes +/* eslint-disable quotes */ + +describe('CDK Include', () => { + let stack: core.Stack; + + beforeEach(() => { + stack = new core.Stack(); + }); + + test('can ingest a template with only an empty S3 Bucket, and output it unchanged', () => { + includeTestTemplate(stack, 'only-empty-bucket.json'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('only-empty-bucket.json'), + ); + }); + + test('throws an exception if asked for resource with a logical ID not present in the template', () => { + const cfnTemplate = includeTestTemplate(stack, 'only-empty-bucket.json'); + + expect(() => { + cfnTemplate.getResource('LogicalIdThatDoesNotExist'); + }).toThrow(/Resource with logical ID 'LogicalIdThatDoesNotExist' was not found in the template/); + }); + + test('can ingest a template with only an empty S3 Bucket, and change its property', () => { + const cfnTemplate = includeTestTemplate(stack, 'only-empty-bucket.json'); + + const cfnBucket = cfnTemplate.getResource('Bucket') as s3.CfnBucket; + cfnBucket.bucketName = 'my-bucket-name'; + + expect(stack).toMatchTemplate({ + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "my-bucket-name", + }, + }, + }, + }); + }); + + test('can ingest a template with only an S3 Bucket with complex properties, and output it unchanged', () => { + const cfnTemplate = includeTestTemplate(stack, 'only-bucket-complex-props.json'); + const cfnBucket = cfnTemplate.getResource('Bucket') as s3.CfnBucket; + + expect((cfnBucket.corsConfiguration as any).corsRules).toHaveLength(1); + expect(stack).toMatchTemplate( + loadTestFileToJsObject('only-bucket-complex-props.json'), + ); + }); + + test('allows referring to a bucket defined in the template in your CDK code', () => { + const cfnTemplate = includeTestTemplate(stack, 'only-empty-bucket.json'); + const cfnBucket = cfnTemplate.getResource('Bucket') as s3.CfnBucket; + + const role = new iam.Role(stack, 'Role', { + assumedBy: new iam.AnyPrincipal(), + }); + role.addToPolicy(new iam.PolicyStatement({ + actions: ['s3:*'], + resources: [cfnBucket.attrArn], + })); + + expect(stack).toHaveResourceLike('AWS::IAM::Policy', { + "PolicyDocument": { + "Statement": [ + { + "Action": "s3:*", + "Resource": { + "Fn::GetAtt": [ + "Bucket", + "Arn", + ], + }, + }, + ], + }, + }); + }); + + test('can ingest a template with a Bucket Ref-erencing a KMS Key, and output it unchanged', () => { + includeTestTemplate(stack, 'bucket-with-encryption-key.json'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('bucket-with-encryption-key.json'), + ); + }); + + xtest('correctly changes the logical IDs, including references, if imported with preserveLogicalIds=false', () => { + const cfnTemplate = includeTestTemplate(stack, 'bucket-with-encryption-key.json', { + preserveLogicalIds: false, + }); + + // even though the logical IDs in the resulting template are different than in the input template, + // the L1s can still be retrieved using their original logical IDs from the template file, + // and any modifications to them will be reflected in the resulting template + const cfnBucket = cfnTemplate.getResource('Bucket') as s3.CfnBucket; + cfnBucket.bucketName = 'my-bucket-name'; + + expect(stack).toMatchTemplate({ + "Resources": { + "MyScopeKey7673692F": { + "Type": "AWS::KMS::Key", + "Properties": { + "KeyPolicy": { + "Statement": [ + { + "Action": [ + "kms:*", + ], + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": ["", [ + "arn:", + { "Ref": "AWS::Partition" }, + ":iam::", + { "Ref": "AWS::AccountId" }, + ":root", + ]], + }, + }, + "Resource": "*", + }, + ], + "Version": "2012-10-17", + }, + }, + "DeletionPolicy": "Delete", + "UpdateReplacePolicy": "Delete", + }, + "MyScopeBucket02C1313B": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "my-bucket-name", + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "ServerSideEncryptionByDefault": { + "KMSMasterKeyID": { + "Fn::GetAtt": [ + "MyScopeKey7673692F", + "Arn", + ], + }, + "SSEAlgorithm": "aws:kms", + }, + }, + ], + }, + }, + "DeletionPolicy": "Retain", + "UpdateReplacePolicy": "Retain", + }, + }, + }); + }); + + test('can ingest a template with an Fn::If expression for simple values, and output it unchanged', () => { + includeTestTemplate(stack, 'if-simple-property.json'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('if-simple-property.json'), + ); + }); + + test('can ingest a template with an Fn::If expression for complex values, and output it unchanged', () => { + includeTestTemplate(stack, 'if-complex-property.json'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('if-complex-property.json'), + ); + }); + + test('can ingest a template with a Ref expression for an array value, and output it unchanged', () => { + includeTestTemplate(stack, 'ref-array-property.json'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('ref-array-property.json'), + ); + }); + + test('renders non-Resources sections unchanged', () => { + includeTestTemplate(stack, 'only-empty-bucket-with-parameters.json'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('only-empty-bucket-with-parameters.json'), + ); + }); + + test("throws an exception when encountering a Resource type it doesn't recognize", () => { + expect(() => { + includeTestTemplate(stack, 'non-existent-resource-type.json'); + }).toThrow(/Unrecognized CloudFormation resource type: 'AWS::FakeService::DoesNotExist'/); + }); + + test("throws an exception when encountering a CFN function it doesn't support", () => { + expect(() => { + includeTestTemplate(stack, 'only-codecommit-repo-using-cfn-functions.json'); + }).toThrow(/Unsupported CloudFormation function 'Fn::Base64'/); + }); + + test('throws an exception when encountering the Condition attribute in a resource', () => { + expect(() => { + includeTestTemplate(stack, 'resource-attribute-condition.json'); + }).toThrow(/The Condition resource attribute is not supported by cloudformation-include yet/); + }); + + test('throws an exception when encountering the DependsOn attribute in a resource', () => { + expect(() => { + includeTestTemplate(stack, 'resource-attribute-depends-on.json'); + }).toThrow(/The DependsOn resource attribute is not supported by cloudformation-include yet/); + }); + + test('throws an exception when encountering the CreationPolicy attribute in a resource', () => { + expect(() => { + includeTestTemplate(stack, 'resource-attribute-creation-policy.json'); + }).toThrow(/The CreationPolicy resource attribute is not supported by cloudformation-include yet/); + }); + + test('throws an exception when encountering the UpdatePolicy attribute in a resource', () => { + expect(() => { + includeTestTemplate(stack, 'resource-attribute-update-policy.json'); + }).toThrow(/The UpdatePolicy resource attribute is not supported by cloudformation-include yet/); + }); +}); + +interface IncludeTestTemplateProps { + /** @default true */ + readonly preserveLogicalIds?: boolean; +} + +function includeTestTemplate(scope: core.Construct, testTemplate: string, _props: IncludeTestTemplateProps = {}): inc.CfnInclude { + return new inc.CfnInclude(scope, 'MyScope', { + templateFile: _testTemplateFilePath(testTemplate), + // preserveLogicalIds: props.preserveLogicalIds, + }); +} + +function loadTestFileToJsObject(testTemplate: string): any { + return futils.readJsonSync(_testTemplateFilePath(testTemplate)); +} + +function _testTemplateFilePath(testTemplate: string) { + return path.join(__dirname, 'test-templates', testTemplate); +} diff --git a/packages/@aws-cdk/core/lib/cfn-fn.ts b/packages/@aws-cdk/core/lib/cfn-fn.ts index 889eef8fcc40d..30fb6cf435bec 100644 --- a/packages/@aws-cdk/core/lib/cfn-fn.ts +++ b/packages/@aws-cdk/core/lib/cfn-fn.ts @@ -22,6 +22,11 @@ export class Fn { return new FnRef(logicalName).toString(); } + /** @internal */ + public static _ref(logicalId: string): IResolvable { + return new FnRef(logicalId); + } + /** * The ``Fn::GetAtt`` intrinsic function returns the value of an attribute * from a resource in the template. diff --git a/packages/@aws-cdk/core/lib/cfn-parse.ts b/packages/@aws-cdk/core/lib/cfn-parse.ts new file mode 100644 index 0000000000000..a72f59240776c --- /dev/null +++ b/packages/@aws-cdk/core/lib/cfn-parse.ts @@ -0,0 +1,215 @@ +import { Fn } from './cfn-fn'; +import { Aws } from './cfn-pseudo'; +import { CfnDeletionPolicy } from './cfn-resource-policy'; +import { CfnTag } from './cfn-tag'; +import { IResolvable } from './resolvable'; +import { isResolvableObject, Token } from './token'; + +/** + * This class contains functions for translating from a pure CFN value + * (like a JS object { "Ref": "Bucket" }) + * to a form CDK understands + * (like Fn.ref('Bucket')). + * + * While this file not exported from the module + * (to not make it part of the public API), + * it is directly referenced in the generated L1 code, + * so any renames of it need to be reflected in cfn2ts/codegen.ts as well. + * + * @experimental + */ +export class FromCloudFormation { + public static parseValue(cfnValue: any): any { + return parseCfnValueToCdkValue(cfnValue); + } + + // nothing to for any but return it + public static getAny(value: any) { return value; } + + // nothing to do - if 'value' is not a boolean or a Token, + // a validator should report that at runtime + public static getBoolean(value: any): boolean | IResolvable { return value; } + + public static getDate(value: any): Date | IResolvable { + // if the date is a deploy-time value, just return it + if (isResolvableObject(value)) { + return value; + } + + // if the date has been given as a string, convert it + if (typeof value === 'string') { + return new Date(value); + } + + // all other cases - just return the value, + // if it's not a Date, a validator should catch it + return value; + } + + public static getString(value: any): string { + // if the string is a deploy-time value, serialize it to a Token + if (isResolvableObject(value)) { + return value.toString(); + } + + // in all other cases, just return the input, + // and let a validator handle it if it's not a string + return value; + } + + public static getNumber(value: any): number { + // if the string is a deploy-time value, serialize it to a Token + if (isResolvableObject(value)) { + return Token.asNumber(value); + } + + // in all other cases, just return the input, + // and let a validator handle it if it's not a number + return value; + } + + public static getStringArray(value: any): string[] { + // if the array is a deploy-time value, serialize it to a Token + if (isResolvableObject(value)) { + return Token.asList(value); + } + + // in all other cases, delegate to the standard mapping logic + return this.getArray(value, this.getString); + } + + public static getArray(value: any, mapper: (arg: any) => T): T[] { + if (!Array.isArray(value)) { + // break the type system, and just return the given value, + // which hopefully will be reported as invalid by the validator + // of the property we're transforming + // (unless it's a deploy-time value, + // which we can't map over at build time anyway) + return value; + } + + return value.map(mapper); + } + + public static getMap(value: any, mapper: (arg: any) => T): { [key: string]: T } { + if (typeof value !== 'object') { + // if the input is not a map (= object in JS land), + // just return it, and let the validator of this property handle it + // (unless it's a deploy-time value, + // which we can't map over at build time anyway) + return value; + } + + const ret: { [key: string]: T } = {}; + for (const [key, val] of Object.entries(value)) { + ret[key] = mapper(val); + } + return ret; + } + + public static parseDeletionPolicy(policy: any): CfnDeletionPolicy | undefined { + switch (policy) { + case null: return undefined; + case undefined: return undefined; + case 'Delete': return CfnDeletionPolicy.DELETE; + case 'Retain': return CfnDeletionPolicy.RETAIN; + case 'Snapshot': return CfnDeletionPolicy.SNAPSHOT; + default: throw new Error(`Unrecognized DeletionPolicy '${policy}'`); + } + } + + public static getCfnTag(tag: any): CfnTag { + return tag == null + ? { } as any // break the type system - this should be detected at runtime by a tag validator + : { + key: tag.Key, + value: tag.Value, + }; + } +} + +function parseCfnValueToCdkValue(cfnValue: any): any { + // == null captures undefined as well + if (cfnValue == null) { + return undefined; + } + // if we have any late-bound values, + // just return them + if (isResolvableObject(cfnValue)) { + return cfnValue; + } + if (Array.isArray(cfnValue)) { + return cfnValue.map(el => parseCfnValueToCdkValue(el)); + } + if (typeof cfnValue === 'object') { + // an object can be either a CFN intrinsic, or an actual object + const cfnIntrinsic = parseIfCfnIntrinsic(cfnValue); + if (cfnIntrinsic) { + return cfnIntrinsic; + } + const ret: any = {}; + for (const [key, val] of Object.entries(cfnValue)) { + ret[key] = parseCfnValueToCdkValue(val); + } + return ret; + } + // in all other cases, just return the input + return cfnValue; +} + +function parseIfCfnIntrinsic(object: any): any { + const key = looksLikeCfnIntrinsic(object); + switch (key) { + case undefined: + return undefined; + case 'Ref': { + // ToDo handle translating logical IDs + return specialCaseRefs(object[key]) ?? Fn._ref(object[key]); + } + case 'Fn::GetAtt': { + // Fn::GetAtt takes a 2-element list as its argument + const value = object[key]; + // ToDo same comment here as in Ref above + return Fn.getAtt((value[0]), value[1]); + } + case 'Fn::Join': { + // Fn::Join takes a 2-element list as its argument, + // where the first element is the delimiter, + // and the second is the list of elements to join + const value = parseCfnValueToCdkValue(object[key]); + return Fn.join(value[0], value[1]); + } + case 'Fn::If': { + // Fn::If takes a 3-element list as its argument + const value = parseCfnValueToCdkValue(object[key]); + return Fn.conditionIf(value[0], value[1], value[2]); + } + default: + throw new Error(`Unsupported CloudFormation function '${key}'`); + } +} + +function looksLikeCfnIntrinsic(object: object): string | undefined { + const objectKeys = Object.keys(object); + // a CFN intrinsic is always an object with a single key + if (objectKeys.length !== 1) { + return undefined; + } + + const key = objectKeys[0]; + return key === 'Ref' || key.startsWith('Fn::') ? key : undefined; +} + +function specialCaseRefs(value: any): any { + switch (value) { + case 'AWS::AccountId': return Aws.ACCOUNT_ID; + case 'AWS::Region': return Aws.REGION; + case 'AWS::Partition': return Aws.PARTITION; + case 'AWS::URLSuffix': return Aws.URL_SUFFIX; + case 'AWS::NotificationARNs': return Aws.NOTIFICATION_ARNS; + case 'AWS::StackId': return Aws.STACK_ID; + case 'AWS::StackName': return Aws.STACK_NAME; + case 'AWS::NoValue': return Aws.NO_VALUE; + default: return undefined; + } +} diff --git a/packages/@aws-cdk/core/lib/runtime.ts b/packages/@aws-cdk/core/lib/runtime.ts index ad8077cc4631d..b475679338129 100644 --- a/packages/@aws-cdk/core/lib/runtime.ts +++ b/packages/@aws-cdk/core/lib/runtime.ts @@ -333,10 +333,10 @@ export function requiredValidator(x: any) { * @throws if the property ``name`` is not present in ``props``. */ export function requireProperty(props: { [name: string]: any }, name: string, context: Construct): any { - if (!(name in props)) { + const value = props[name]; + if (value == null) { throw new Error(`${context.toString()} is missing required property: ${name}`); } - const value = props[name]; // Possibly add type-checking here... return value; } diff --git a/packages/@aws-cdk/core/test/test.logical-id.ts b/packages/@aws-cdk/core/test/test.logical-id.ts index 4310424d23796..332dad2f14eba 100644 --- a/packages/@aws-cdk/core/test/test.logical-id.ts +++ b/packages/@aws-cdk/core/test/test.logical-id.ts @@ -255,6 +255,21 @@ export = { }); test.done(); }, + + 'detects duplicate logical IDs in the same Stack caused by overrideLogicalId'(test: Test) { + const stack = new Stack(); + const resource1 = new CfnResource(stack, 'A', { type: 'Type::Of::A' }); + const resource2 = new CfnResource(stack, 'B', { type: 'Type::Of::B' }); + + resource1.overrideLogicalId('C'); + resource2.overrideLogicalId('C'); + + test.throws(() => { + toCloudFormation(stack); + }, /section 'Resources' already contains 'C'/); + + test.done(); + }, }; function generateString(chars: number) { @@ -277,4 +292,4 @@ function logicalForElementInPath(constructPath: string[]): string { } return stack.resolve((scope as CfnResource).logicalId); -} \ No newline at end of file +} diff --git a/packages/decdk/deps.js b/packages/decdk/deps.js index e6087c7163690..c7b66f27d0827 100644 --- a/packages/decdk/deps.js +++ b/packages/decdk/deps.js @@ -21,6 +21,11 @@ for (const dir of modules) { continue; } + // skip the `@aws-cdk/cloudformation-include` module + if (dir === 'cloudformation-include') { + continue; + } + const exists = deps[meta.name]; if (meta.deprecated) { diff --git a/packages/monocdk-experiment/package.json b/packages/monocdk-experiment/package.json index ad79692e340a3..8cf0595c09591 100644 --- a/packages/monocdk-experiment/package.json +++ b/packages/monocdk-experiment/package.json @@ -178,6 +178,7 @@ "@aws-cdk/aws-wafv2": "0.0.0", "@aws-cdk/aws-workspaces": "0.0.0", "@aws-cdk/cdk-assets-schema": "0.0.0", + "@aws-cdk/cloudformation-include": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/custom-resources": "0.0.0", "@aws-cdk/cx-api": "0.0.0", diff --git a/tools/cfn2ts/lib/codegen.ts b/tools/cfn2ts/lib/codegen.ts index 289841e911a1a..0689be9027ac4 100644 --- a/tools/cfn2ts/lib/codegen.ts +++ b/tools/cfn2ts/lib/codegen.ts @@ -1,12 +1,11 @@ import { schema } from '@aws-cdk/cfnspec'; import { CodeMaker } from 'codemaker'; -import * as fs from 'fs-extra'; -import * as path from 'path'; import * as genspec from './genspec'; import { itemTypeNames, PropertyAttributeName, scalarTypeNames, SpecName } from './spec-utils'; import { upcaseFirst } from './util'; const CORE = genspec.CORE_NAMESPACE; +const CFN_PARSE = genspec.CFN_PARSE_NAMESPACE; const RESOURCE_BASE_CLASS = `${CORE}.CfnResource`; // base class for all resources const CONSTRUCT_CLASS = `${CORE}.Construct`; const TAG_TYPE = `${CORE}.TagType`; @@ -59,26 +58,8 @@ export default class CodeGenerator { this.code.line('// tslint:disable:max-line-length | This is generated code - line lengths are difficult to control'); this.code.line(); this.code.line(`import * as ${CORE} from '${coreImport}';`); - } - - public async upToDate(outPath: string): Promise { - const fullPath = path.join(outPath, this.outputFile); - if (!await fs.pathExists(fullPath)) { - return false; - } - const data = await fs.readFile(fullPath, { encoding: 'utf-8' }); - const comment = data.match(/^\s*[/]{2}\s*@cfn2ts:meta@(.+)$/m); - if (comment) { - try { - const meta = JSON.parse(comment[1]); - if (meta.fingerprint === this.spec.Fingerprint) { - return true; - } - } catch { - return false; - } - } - return false; + // explicitly import the cfn-parse.ts file from @core, which is not part of the public API of the module + this.code.line(`import * as ${CFN_PARSE} from '${coreImport}/${coreImport === '.' ? '' : 'lib/'}cfn-parse';`); } public emitCode(): void { @@ -147,6 +128,7 @@ export default class CodeGenerator { this.emitValidator(resourceContext, name, spec.Properties, conversionTable); this.code.line(); this.emitCloudFormationMapper(resourceContext, name, spec.Properties, conversionTable); + this.emitFromCfnFactoryFunction(resourceContext, name, spec.Properties, conversionTable, false); return name; } @@ -209,6 +191,10 @@ export default class CodeGenerator { this.code.line(); } + // + // The class declaration representing this Resource + // + this.docLink(spec.Documentation, `A CloudFormation \`${cfnName}\``, '', @@ -233,6 +219,48 @@ export default class CodeGenerator { this.code.line(`public static readonly REQUIRED_TRANSFORM = ${JSON.stringify(spec.RequiredTransform)};`); } + // + // The static fromCloudFormation() method, + // used in the @aws-cdk/cloudformation-include module + // + + this.code.line(); + this.code.line('/**'); + this.code.line(' * A factory method that creates a new instance of this class from an object'); + this.code.line(' * containing the CloudFormation properties of this resource.'); + this.code.line(' * Used in the @aws-cdk/cloudformation-include module.'); + this.code.line(' *'); + this.code.line(' * @experimental'); + this.code.line(' */'); + this.code.openBlock(`public static fromCloudFormation(scope: ${CONSTRUCT_CLASS}, id: string, resourceAttributes: any): ` + + `${resourceName.className}`); + this.code.line('resourceAttributes = resourceAttributes || {};'); + if (propsType) { + // translate the template properties to CDK objects + this.code.line(`const resourceProperties = ${CFN_PARSE}.FromCloudFormation.parseValue(resourceAttributes.Properties);`); + // translate to props, using a (module-private) factory function + this.code.line(`const props = ${genspec.fromCfnFactoryName(propsType).fqn}(resourceProperties);`); + // finally, instantiate the resource class + this.code.line(`const ret = new ${resourceName.className}(scope, id, props);`); + } else { + // no props type - we simply instantiate the construct without the third argument + this.code.line(`const ret = new ${resourceName.className}(scope, id);`); + } + // handle all non-property attributes + // (retention policies, conditions, metadata, etc.) + this.code.line('const cfnOptions = ret.cfnOptions;'); + this.code.line(`cfnOptions.deletionPolicy = ${CFN_PARSE}.FromCloudFormation.parseDeletionPolicy(resourceAttributes.DeletionPolicy);`); + this.code.line(`cfnOptions.updateReplacePolicy = ${CFN_PARSE}.FromCloudFormation.parseDeletionPolicy(resourceAttributes.UpdateReplacePolicy);`); + this.code.line(`cfnOptions.metadata = ${CFN_PARSE}.FromCloudFormation.parseValue(resourceAttributes.Metadata);`); + // ToDo handle: + // 1. Condition + // 2. CreationPolicy + // 3. UpdatePolicy + // 4. DependsOn + + this.code.line('return ret;'); + this.code.closeBlock(); + // // Attributes // @@ -254,7 +282,10 @@ export default class CodeGenerator { } } - // set class properties to match CloudFormation Properties spec + // + // Set class properties to match CloudFormation Properties spec + // + let propMap; if (propsType) { propMap = this.emitPropsTypeProperties(resourceName, spec.Properties!, Container.Class); @@ -478,6 +509,132 @@ export default class CodeGenerator { this.code.closeBlock(); } + /** + * Generates a function that converts from a pure CloudFormation value taken from a template + * to an instance of the given CDK struct. + * This involves changing the casing of the properties, + * from UpperCamelCase used by CloudFormation, + * to lowerCamelCase used by the CDK, + * and also translating things like IResolvable into strings, numbers or string arrays, + * depending on the type of the L1 property. + */ + private emitFromCfnFactoryFunction( + resource: genspec.CodeName, + typeName: genspec.CodeName, + propSpecs: { [name: string]: schema.Property }, + nameConversionTable: Dictionary, + allowReturningIResolvable: boolean) { + + const factoryName = genspec.fromCfnFactoryName(typeName); + + this.code.line(); + // Do not error out if this function is unused. + // Some types are declared in the CFN schema, + // but never used as types of properties, + // and in those cases this function will never be called. + this.code.line('// @ts-ignore TS6133'); + this.code.openBlock(`function ${factoryName.functionName}(properties: any): ${typeName.fqn}` + + (allowReturningIResolvable ? ` | ${CORE}.IResolvable` : '')); + + if (allowReturningIResolvable) { + this.code.openBlock(`if (${CORE}.isResolvableObject(properties))`); + this.code.line('return properties;'); + this.code.closeBlock(); + } + + this.code.line('properties = properties || {};'); + // Generate the return object + this.code.indent('return {'); + const self = this; + + // class used for the visitor + class FromCloudFormationFactoryVisitor implements genspec.PropertyVisitor { + constructor( + private readonly baseExpression: string, + private readonly optionalProperty: boolean, + private readonly cfnPropName: string, + private readonly depth: number = 1) { + } + + public visitAtom(type: genspec.CodeName): string { + const specType = type.specName && self.spec.PropertyTypes[type.specName.fqn]; + if (specType && !schema.isRecordType(specType)) { + return genspec.typeDispatch(resource, specType, this); + } else { + const optionalPreamble = this.optionalProperty + ? `${this.baseExpression} == null ? undefined : ` + : ''; + const suffix = schema.isTagPropertyName(this.cfnPropName) + // Properties that have names considered to denote tags + // have their type generated without a union with IResolvable. + // However, we can't possibly know that when generating the factory + // for that struct, and (in theory, at least) + // the same type can be used as the value of multiple properties, + // some of which do not have a tag-compatible name, + // so there is no way to pass allowReturningIResolvable=false correctly. + // Do the simple thing in that case, and just cast to any. + ? ' as any' + : ''; + return `${optionalPreamble}${genspec.fromCfnFactoryName(type).fqn}(${this.baseExpression})${suffix}`; + } + } + + public visitList(itemType: genspec.CodeName): string { + const arg = `prop${this.depth}`; + return itemType.className === 'string' + // an array of strings is a special case, + // because it might need to be encoded as a Token directly + // (and not an array of tokens), for example, + // when a Ref expression references a parameter of type CommaDelimitedList + ? `${CFN_PARSE}.FromCloudFormation.getStringArray(${this.baseExpression})` + : `${CFN_PARSE}.FromCloudFormation.getArray(${this.baseExpression}, (${arg}: any) => ` + + `${this.deeperCopy(arg).visitAtom(itemType)})`; + } + + public visitMap(itemType: genspec.CodeName): string { + const arg = `prop${this.depth}`; + return `${CFN_PARSE}.FromCloudFormation.getMap(${this.baseExpression}, (${arg}: any) => ` + + `${this.deeperCopy(arg).visitAtom(itemType)})`; + } + + public visitAtomUnion(_types: genspec.CodeName[]): string { + return this.baseExpression; + } + + public visitListOrAtom(_scalarTypes: genspec.CodeName[], _itemTypes: genspec.CodeName[]): any { + return this.baseExpression; + } + + public visitUnionList(_itemTypes: genspec.CodeName[]): string { + return this.baseExpression; + } + + public visitUnionMap(_itemTypes: genspec.CodeName[]): string { + return this.baseExpression; + } + + private deeperCopy(baseExpression: string): FromCloudFormationFactoryVisitor { + return new FromCloudFormationFactoryVisitor(baseExpression, false, this.cfnPropName, this.depth + 1); + } + } + + Object.keys(nameConversionTable).forEach(cfnName => { + const propName = nameConversionTable[cfnName]; + const propSpec = propSpecs[cfnName]; + + const simpleCfnPropAccessExpr = `properties.${cfnName}`; + const mapperExpression = genspec.typeDispatch(resource, propSpec, + new FromCloudFormationFactoryVisitor(simpleCfnPropAccessExpr, !propSpec.Required, cfnName)); + + self.code.line(`${propName}: ${mapperExpression},`); + }); + // close the return object brace + this.code.unindent('};'); + + // close the function brace + this.code.closeBlock(); + } + /** * Emit a function that will validate whether the given property bag matches the schema of this complex type * @@ -635,6 +792,7 @@ export default class CodeGenerator { this.emitValidator(resourceContext, typeName, propTypeSpec.Properties, conversionTable); this.code.line(); this.emitCloudFormationMapper(resourceContext, typeName, propTypeSpec.Properties, conversionTable); + this.emitFromCfnFactoryFunction(resourceContext, typeName, propTypeSpec.Properties, conversionTable, true); } /** diff --git a/tools/cfn2ts/lib/genspec.ts b/tools/cfn2ts/lib/genspec.ts index 3e27aea65010f..63df05ef82115 100644 --- a/tools/cfn2ts/lib/genspec.ts +++ b/tools/cfn2ts/lib/genspec.ts @@ -10,6 +10,7 @@ import * as util from './util'; const RESOURCE_CLASS_PREFIX = 'Cfn'; export const CORE_NAMESPACE = 'cdk'; +export const CFN_PARSE_NAMESPACE = 'cfn_parse'; /** * The name of a class or method in the generated code. @@ -149,6 +150,28 @@ export function cfnMapperName(typeName: CodeName): CodeName { return new CodeName(typeName.packageName, '', util.downcaseFirst(`${typeName.namespace}${typeName.className}ToCloudFormation`)); } +/** + * Return the name of the function that converts a pure CloudFormation value + * to the appropriate CDK struct instance. + */ +export function fromCfnFactoryName(typeName: CodeName): CodeName { + if (isPrimitive(typeName)) { + // primitive types are handled by specialized functions from @aws-cdk/core + return new CodeName('', CFN_PARSE_NAMESPACE, 'FromCloudFormation', undefined, `get${util.upcaseFirst(typeName.className)}`); + } else if (isCloudFormationTagCodeName(typeName)) { + // tags, since they are shared, have their own function in @aws-cdk/core + return new CodeName('', CFN_PARSE_NAMESPACE, 'FromCloudFormation', undefined, 'getCfnTag'); + } else { + return new CodeName(typeName.packageName, '', `${typeName.namespace}${typeName.className}FromCloudFormation`); + } +} + +function isCloudFormationTagCodeName(codeName: CodeName): boolean { + return codeName.className === TAG_NAME.className && + codeName.packageName === TAG_NAME.packageName && + codeName.namespace === TAG_NAME.namespace; +} + /** * Return the name for the type-checking method */