diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 587a6139fdffc..a9bdea2c823e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -131,6 +131,11 @@ Work your magic. Here are some guidelines: * Try to maintain a single feature/bugfix per pull request. It's okay to introduce a little bit of housekeeping changes along the way, but try to avoid conflating multiple features. Eventually all these are going to go into a single commit, so you can use that to frame your scope. +* If your change introduces a new construct, take a look at the our + [example Construct Library](packages/@aws-cdk/example-construct-library) for an explanation of the common patterns we use. + Feel free to start your contribution by copy&pasting files from that project, + and then edit and rename them as appropriate - + it might be easier to get started that way. #### Integration Tests diff --git a/packages/@aws-cdk/example-construct-library/.eslintrc.js b/packages/@aws-cdk/example-construct-library/.eslintrc.js new file mode 100644 index 0000000000000..1b28bad193ceb --- /dev/null +++ b/packages/@aws-cdk/example-construct-library/.eslintrc.js @@ -0,0 +1,2 @@ +const baseConfig = require('../../../tools/cdk-build-tools/config/eslintrc'); +module.exports = baseConfig; diff --git a/packages/@aws-cdk/example-construct-library/.gitignore b/packages/@aws-cdk/example-construct-library/.gitignore new file mode 100644 index 0000000000000..0bd6133da4d09 --- /dev/null +++ b/packages/@aws-cdk/example-construct-library/.gitignore @@ -0,0 +1,16 @@ +*.js +*.js.map +*.d.ts +tsconfig.json +node_modules +*.generated.ts +dist +.jsii + +.LAST_BUILD +.nyc_output +coverage +nyc.config.js +.LAST_PACKAGE +*.snk +!.eslintrc.js diff --git a/packages/@aws-cdk/example-construct-library/.npmignore b/packages/@aws-cdk/example-construct-library/.npmignore new file mode 100644 index 0000000000000..174864d493a79 --- /dev/null +++ b/packages/@aws-cdk/example-construct-library/.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 + +# Include .jsii +!.jsii + +*.snk + +*.tsbuildinfo + +tsconfig.json +.eslintrc.js diff --git a/packages/@aws-cdk/example-construct-library/LICENSE b/packages/@aws-cdk/example-construct-library/LICENSE new file mode 100644 index 0000000000000..b71ec1688783a --- /dev/null +++ b/packages/@aws-cdk/example-construct-library/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/example-construct-library/NOTICE b/packages/@aws-cdk/example-construct-library/NOTICE new file mode 100644 index 0000000000000..bfccac9a7f69c --- /dev/null +++ b/packages/@aws-cdk/example-construct-library/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/example-construct-library/README.md b/packages/@aws-cdk/example-construct-library/README.md new file mode 100644 index 0000000000000..74a34f0a2c484 --- /dev/null +++ b/packages/@aws-cdk/example-construct-library/README.md @@ -0,0 +1,90 @@ +## An example Construct Library module + + +--- + +![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 package contains an example CDK construct library +for an imaginary resource called ExampleResource. +Its target audience are construct library authors - +both when contributing to the core CDK codebase, +or when writing your own construct library. + +Even though different construct libraries model vastly different services, +a large percentage of the structure of the construct libraries +(what we often call Layer 2 constructs, or L2s for short) +is actually strikingly similar between all of them. +This module hopes to present a skeleton of that structure, +that you can literally copy&paste to your own construct library, +and then edit to suit your needs. +It also attempts to explain the elements of that skeleton as best as it can, +through inline comments on the code itself. + +## Using when contributing to the CDK codebase + +If you're creating a completely new module, +feel free to copy&paste this entire directory, +and then edit the `package.json` and `README.md` +files as necessary (see the "Package structure" section below). +Make sure to remove the `"private": true` line from `package.json` +after copying, as otherwise your package will not be published! + +If you're contributing a new resource to an existing package, +feel free to copy&paste the following files, +instead of the entire package: + +* [`lib/example-resource.ts`](lib/example-resource.ts) +* [`lib/private/example-resource-common.ts`](lib/private/example-resource-common.ts) +* [`test/example-resource.test.ts`](test/example-resource.test.ts) +* [`test/integ.example-resource.ts`](test/integ.example-resource.ts) +* [`test/integ.example-resource.expected.json`](test/integ.example-resource.expected.json) + +And proceed to edit and rename them from there. + +## Using for your own construct libraries + +Feel free to use this package as the basis of your own construct library; +note, however, that you will have to change a few things in `package.json` to get it to build: + +* Remove the `"private": true` flag if you intend to publish your package to npmjs.org + (see https://docs.npmjs.com/files/package.json#private for details). +* Remove the `devDependencies` on `cdk-build-tools`, `cdk-integ-tools` and `pkglint`. +* Remove the `lint`, `integ`, `pkglint`, `package`, `build+test+package`, `awslint`, and `compat` entries in the `scripts` section. +* The `build` script should be just `tsc`, `watch` just `tsc -w`, and `test` just `jest`. +* Finally, the `awscdkio` key should be completely removed. + +You will also have to get rid of the integration test files, +[`test/integ.example-resource.ts`](test/integ.example-resource.ts) and +[`test/integ.example-resource.expected.json`](test/integ.example-resource.expected.json), +as those styles of integration tests are not available outside the CDK main repo. + +## Code structure + +The code structure is explained through inline comments in the files themselves. +Probably [`lib/example-resource.ts`](lib/example-resource.ts) is a good place to start reading. + +### Tests + +The package contains examples of unit tests in the [`test/example-resource.test.ts`](test/example-resource.test.ts) +file. + +It also contains an example integration test in [`test/integ.example-resource.ts`](test/integ.example-resource.ts). +For more information on CDK integ tests, see the +[main `Contributing.md` file](../../../CONTRIBUTING.md#integration-tests). + +## Package structure + +The package uses the standard build and test tools available in the CDK repo. +Even though it's not published, +it also uses [JSII](https://github.com/aws/jsii), +the technology that allows CDK logic to be written once, +but used from multiple programming languages. +Its configuration lives the `jsii` key in `package.json`. +It's mainly used as a validation tool in this package, +as JSII places some constraints on the TypeScript code that you can write. diff --git a/packages/@aws-cdk/example-construct-library/lib/example-resource.ts b/packages/@aws-cdk/example-construct-library/lib/example-resource.ts new file mode 100644 index 0000000000000..f2ad8ea9e2807 --- /dev/null +++ b/packages/@aws-cdk/example-construct-library/lib/example-resource.ts @@ -0,0 +1,510 @@ +/* + * We always import other construct libraries entirely with a prefix - + * we never import individual classes from them without a qualifier + * (the prefix makes it more obvious where a given dependency comes from, + * and prevents conflicting names causing issues). + * Our linter also enforces ES6-style imports - + * we don't use TypeScript's import a = require('a') imports. + */ +import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as events from '@aws-cdk/aws-events'; +import * as iam from '@aws-cdk/aws-iam'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as core from '@aws-cdk/core'; +// for files that are part of this package, we do import individual classes or functions +import { exampleResourceArnComponents } from './private/example-resource-common'; + +/** + * The interface that represents the ExampleResource resource. + * We always use an interface, because each L2 resource type in the CDK can occur in two aspects: + * + * 1. It can be a resource that's created and managed by the CDK. + * Those resources are represented by the class with the name identical to the resource - + * {@link ExampleResource} in our case, which implements {@link IExampleResource}. + * 2. It can be a resource that exists already, and is not managed by the CDK code, + * but needs to be referenced in your infrastructure definition code. + * Those kinds of instances are returned from static `fromXyz(Name/Arn/Attributes)` methods - + * in our case, the {@link ExampleResource.fromExampleResourceName} method. + * In general, those kinds of resources do not allow any sort of mutating operations to be performed on them + * (the exception is when they can be changed by creating a different resource - + * IAM Roles, which you can attach multiple IAM Policies to, + * are the canonical example of this sort of resource), + * as they are not part of the CloudFormation stack that is created by the CDK. + * + * So, an interface like {@link IExampleResource} represents a resource that *might* be mutable, + * while the {@link ExampleResource} class represents a resource that definitely is mutable. + * Whenever a type that represents this resource needs to referenced in other code, + * you want to use {@link IExampleResource} as the type, not {@link ExampleResource}. + * + * The interface for the resource should have at least 2 (readonly) properties + * that represent the ARN and the physical name of the resource - + * in our example, those are {@link exampleResourceArn} and {@link exampleResourceName}. + * + * The interface defines the behaviors the resource exhibits. + * Common behaviors are: + * - {@link addToRolePolicy} for resources that are tied to an IAM Role + * - grantXyz() methods (represented by {@link grantRead} in this example) + * - onXyz() CloudWatch Events methods (represented by {@link onEvent} in this example) + * - metricXyz() CloudWatch Metric methods (represented by {@link metricCount} in this example) + * + * Of course, other behaviors are possible - + * it all depends on the capabilities of the underlying resource that is being modeled. + * + * This interface must always extend the IResource interface from the core module. + * It can also extend some other common interfaces that add various default behaviors - + * some examples are shown below. + */ +export interface IExampleResource extends + // all L2 interfaces need to extend IResource + core.IResource, + + // Only for resources that have an associated IAM Role. + // Allows this resource to be the target in calls like bucket.grantRead(exampleResource). + iam.IGrantable, + + // only for resources that are in a VPC and have SecurityGroups controlling their traffic + ec2.IConnectable { + + /** + * The ARN of example resource. + * Equivalent to doing `{ 'Fn::GetAtt': ['LogicalId', 'Arn' ]}` + * in CloudFormation if the underlying CloudFormation resource + * surfaces the ARN as a return value - + * if not, we usually construct the ARN "by hand" in the construct, + * using the Fn::Join function. + * + * It needs to be annotated with '@attribute' if the underlying CloudFormation resource + * surfaces the ARN as a return value. + * + * @attribute + */ + readonly exampleResourceArn: string; + + /** + * The physical name of the example resource. + * Often, equivalent to doing `{ 'Ref': 'LogicalId' }` + * (but not always - depends on the particular resource modeled) + * in CloudFormation. + * Also needs to be annotated with '@attribute'. + * + * @attribute + */ + readonly exampleResourceName: string; + + /** + * For resources that have an associated IAM Role, + * surface that Role as a property, + * so that other classes can add permissions to it. + * Make it optional, + * as resources imported with {@link ExampleResource.fromExampleResourceName} + * will not have this set. + */ + readonly role?: iam.IRole; + + /** + * For resources that have an associated IAM Role, + * surface a method that allows you to conditionally + * add a statement to that Role if it's known. + * This is just a convenience, + * so that clients of your interface don't have to check {@link role} for null. + * Many such methods in the CDK return void; + * you can also return a boolean indicating whether the permissions were in fact added + * (so, when {@link role} is not null). + */ + addToRolePolicy(policyStatement: iam.PolicyStatement): boolean; + + /** + * An example of a method that grants the given IAM identity + * permissions to this resource + * (in this case - read permissions). + */ + grantRead(identity: iam.IGrantable): iam.Grant; + + /** + * Add a CloudWatch rule that will use this resource as the source of events. + * Resources that emit events have a bunch of methods like these, + * that allow different resources to be triggered on various events happening to this resource + * (like item added, item updated, item deleted, ect.) - + * exactly which methods you need depends on the resource you're modeling. + */ + onEvent(id: string, options?: events.OnEventOptions): events.Rule; + + /** + * Standard method that allows you to capture metrics emitted by this resource, + * and use them in dashboards and alarms. + * The details of which metric methods you should have of course depends on the + * resource that is being modeled. + */ + metricCount(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric; +} + +/** + * A common abstract superclass that implements the {@link IExampleResource} interface. + * We often have these classes to share code between the {@link ExampleResource} + * class and the {@link IExampleResource} instances returned from methods like + * {@link ExampleResource.fromExampleResourceName}. + * It has to extend the Resource class from the core module. + * + * Notice that the class is not exported - it's not part of the public API of this module! + */ +abstract class ExampleResourceBase extends core.Resource implements IExampleResource { + // these stay abstract at this level + public abstract readonly exampleResourceArn: string; + public abstract readonly exampleResourceName: string; + public abstract readonly role?: iam.IRole; + // this property is needed for the iam.IGrantable interface + public abstract readonly grantPrincipal: iam.IPrincipal; + // This is needed for the ec2.IConnectable interface. + // Allow subclasses to write this field. + // JSII requires all member starting with an underscore to be annotated with '@internal'. + /** @internal */ + protected _connections: ec2.Connections | undefined; + + /** Implement the ec2.IConnectable interface, using the _connections field. */ + public get connections(): ec2.Connections { + if (!this._connections) { + throw new Error('An imported ExampleResource cannot manage its security groups'); + } + return this._connections; + } + + /** Implement the convenience {@link IExampleResource.addToRolePolicy} method. */ + public addToRolePolicy(policyStatement: iam.PolicyStatement): boolean { + if (this.role) { + this.role.addToPolicy(policyStatement); + return true; + } else { + return false; + } + } + + /** Implement the {@link IExampleResource.grantRead} method. */ + public grantRead(identity: iam.IGrantable): iam.Grant { + // usually, we would grant some service-specific permissions here, + // but since this is just an example, let's use S3 + return iam.Grant.addToPrincipal({ + grantee: identity, + actions: ['s3:Get*'], // as many actions as you need + resourceArns: [this.exampleResourceArn], + }); + } + + /** + * Implement the {@link IExampleResource.onEvent} method. + * Notice that we change 'options' from an optional argument to an argument with a default value - + * that's a common trick in the CDK + * (you're not allowed to have default values for arguments in interface methods in TypeScript), + * as it simplifies the implementation code (less branching). + */ + public onEvent(id: string, options: events.OnEventOptions = {}): events.Rule { + const rule = new events.Rule(this, id, options); + rule.addTarget(options.target); + rule.addEventPattern({ + // obviously, you would put your resource-specific values here + source: ['aws.cloudformation'], + detail: { + 'example-resource-name': [this.exampleResourceName], + }, + }); + return rule; + } + + /** Implement the {@link IExampleResource.metricCount} method. */ + public metricCount(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return new cloudwatch.Metric({ + // of course, you would put your resource-specific values here + namespace: 'AWS/ExampleResource', + dimensions: { ExampleResource: this.exampleResourceName }, + metricName, + ...props, + }).attachTo(this); + } +} + +/** + * Construction properties for {@link ExampleResource}. + * All constructs have the same construction pattern: + * you provide a scope of type Construct, + * a string identifier, and a third argument, + * representing the properties specific to that resource. + * That third type is represented in the CDK by an interface + * with only readonly simple properties (no methods), + * sometimes called, in JSII terminology, a 'struct'. + * This is this struct for the {@link ExampleResource} class. + * + * This interface is always called 'Props'. + */ +export interface ExampleResourceProps { + /** + * The physical name of the resource. + * If you don't provide one, CloudFormation will generate one for you. + * Almost all resources, with only a few exceptions, + * allow setting their physical name. + * The name is a little silly, + * because of the @resource annotation on the {@link ExampleResource} class + * (CDK linters make sure those two names are aligned). + * + * @default - CloudFormation-generated name + */ + readonly waitConditionHandleName?: string; + + /** + * Many resources require an IAM Role to function. + * While a customer can provide one, + * the CDK will always create a new one + * (with the correct assumeRole service principal) if it wasn't provided. + * + * @default - a new Role will be created + */ + readonly role?: iam.IRole; + + /** + * Many resources allow passing in an optional S3 Bucket. + * Buckets can also have KMS Keys associated with them, + * so any encryption settings in your resource should check + * for the presence of that property on the passed Bucket. + * + * @default - no Bucket will be used + */ + readonly bucket?: s3.IBucket; + + /** + * Many resources can be attached to a VPC. + * If your resource cannot function without a VPC, + * make this property required - + * do NOT make it optional, and then create a VPC implicitly! + * This is different than what we do for IAM Roles, for example. + * + * @default - no VPC will be used + */ + readonly vpc?: ec2.IVpc; + + /** + * Whenever you have IVpc as a property, + * like we have in {@link vpc}, + * you need to provide an optional property of type ec2.SubnetSelection, + * which can be used to specify which subnets of the VPC should the resource use. + * The default is usually all private subnets, + * however you can change that default in your resource if it makes sense + * (for example, to all public subnets). + * + * @default - default subnet selection strategy, see the EC2 module for details + */ + readonly vpcSubnets?: ec2.SubnetSelection; + + /** + * If your resource interface extends ec2.IConnectable, + * that means it needs security groups to control traffic coming to and from it. + * Allow the customer to specify these security groups. + * If none were specified, we will create a new one implicitly, + * similarly like we do for IAM Roles. + * + * **Note**: a few resources in the CDK only allow you to provide a single SecurityGroup. + * This is generally considered a historical mistake, + * and all new code should allow an array of security groups to be passed. + * + * @default - a new security group will be created + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + + /** + * What to do when this resource is deleted from a stack. + * Some stateful resources cannot be deleted if they have any contents + * (S3 Buckets are the canonical example), + * so we set their deletion policy to RETAIN by default. + * If your resource also behaves like that, + * you need to allow your customers to override this behavior if they need to. + * + * @default RemovalPolicy.RETAIN + */ + readonly removalPolicy?: core.RemovalPolicy; +} + +/** + * The actual L2 class for the ExampleResource. + * Extends ExampleResourceBase. + * Represents a resource completely managed by the CDK, and thus mutable. + * You can add additional methods to the public API of this class not present in {@link IExampleResource}, + * although you should strive to minimize that as much as possible, + * and have the entire API available in {@link IExampleResource} + * (but perhaps some of it not having any effect, + * like {@link IExampleResource.addToRolePolicy}). + * + * Usually, the CDK is able to figure out what's the equivalent CloudFormation resource for this L2, + * but sometimes (like in this example), we need to specify it explicitly. + * You do it with the '@resource' annotation: + * + * @resource AWS::CloudFormation::WaitConditionHandle + */ +export class ExampleResource extends ExampleResourceBase { + /** + * Reference an existing ExampleResource, + * defined outside of the CDK code, by name. + * + * The class might contain more methods for referencing an existing resource, + * like fromExampleResourceArn, + * or fromExampleResourceAttributes + * (the last one if you want the importing behavior to be more customizable). + */ + public static fromExampleResourceName(scope: core.Construct, id: string, exampleResourceName: string): IExampleResource { + // Imports are almost always implemented as a module-private + // inline class in the method itself. + // We extend ExampleResourceBase to reuse all of the logic inside it. + class Import extends ExampleResourceBase { + // we don't have an associated Role in this case + public readonly role = undefined; + // for imported resources, you always use the UnknownPrincipal, + // which ignores all modifications + public readonly grantPrincipal = new iam.UnknownPrincipal({ resource: this }); + + public readonly exampleResourceName = exampleResourceName; + // Since we have the name, we have to generate the ARN, + // using the Stack.formatArn helper method from the core library. + // We have to know the ARN components of ExampleResource in a few places, so, + // to avoid duplication, extract that into a module-private function + public readonly exampleResourceArn = core.Stack.of(scope) + .formatArn(exampleResourceArnComponents(exampleResourceName)); + } + + return new Import(scope, id); + } + + // implement all fields that are abstract in ExampleResourceBase + public readonly exampleResourceArn: string; + public readonly exampleResourceName: string; + // while we know 'role' will actually never be undefined in this class, + // JSII does not allow changing the optionality of a field + // when overriding it, so it has to be 'role?' + public readonly role?: iam.IRole; + public readonly grantPrincipal: iam.IPrincipal; + + /** + * The constructor of a construct has always 3 arguments: + * the parent Construct, the string identifier, + * locally unique within the scope of the parent, + * and a properties struct. + * + * If the props only have optional properties, like in our case, + * make sure to add a default value of an empty object to the props argument. + */ + constructor(scope: core.Construct, id: string, props: ExampleResourceProps = {}) { + // Call the constructor from Resource superclass, + // which attaches this construct to the construct tree. + super(scope, id, { + // You need to let the Resource superclass know which of your properties + // signifies the resource's physical name. + // If your resource doesn't have a physical name, + // don't set this property. + // For more information on what exactly is a physical name, + // see the CDK guide: https://docs.aws.amazon.com/cdk/latest/guide/resources.html#resources_physical_names + physicalName: props.waitConditionHandleName, + }); + + // We often add validations for properties, + // so that customers receive feedback about incorrect properties + // sooner than a CloudFormation deployment. + // However, when validating string (and number!) properties, + // it's important to remember that the value can be a CFN function + // (think a { Ref: ParameterName } expression in CloudFormation), + // and that sort of value would be also encoded as a string; + // so, we need to use the Token.isUnresolved() method from the core library + // to skip validation in that case. + if (props.waitConditionHandleName !== undefined && + !core.Token.isUnresolved(props.waitConditionHandleName) && + !/^[_a-zA-Z]+$/.test(props.waitConditionHandleName)) { + throw new Error('waitConditionHandleName must be non-empty and contain only letters and underscores, ' + + `got: '${props.waitConditionHandleName}'`); + } + + // Inside the implementation of the L2, + // we very often use L1 classes (those whose names begin with 'Cfn'). + // However, it's important we don't 'leak' that fact to the API of the L2 class - + // so, we should never take L1 types as inputs in our props, + // and we should not surface any L1 classes in public fields or methods of the class. + // The 'Cfn*' class is purely an implementation detail. + + // If this was a real resource, we would use a specific L1 for that resource + // (like a CfnBucket inside the Bucket class), + // but since this is just an example, + // we'll use CloudFormation wait conditions. + + // Remember to always, always, pass 'this' as the first argument + // when creating any constructs inside your L2s! + // This guarantees that they get scoped correctly, + // and the CDK will make sure their locally-unique identifiers + // are globally unique, which makes your L2 compose. + const waitConditionHandle = new core.CfnWaitConditionHandle(this, 'WaitConditionHandle'); + + // The 'main' L1 you create should always have the logical ID 'Resource'. + // This is important, so that the ConstructNode.defaultChild method works correctly. + // The local variable representing the L1 is often called 'resource' as well. + const resource = new core.CfnWaitCondition(this, 'Resource', { + count: 0, + handle: waitConditionHandle.ref, + timeout: '10', + }); + + // The resource's physical name and ARN are set using + // some protected methods from the Resource superclass + // that correctly resolve when your L2 is used in another resource + // that is in a different AWS region or account than this one. + this.exampleResourceName = this.getResourceNameAttribute( + // A lot of the CloudFormation resources return their physical name + // when the Ref function is used on them. + // If your resource is like that, simply pass 'resource.ref' here. + // However, if Ref for your resource returns something else, + // it's often still possible to use CloudFormation functions to get out the physical name; + // for example, if Ref for your resource returns the ARN, + // and the ARN for your resource is of the form 'arn:aws::::resource/physical-name', + // which is quite common, + // you can use Fn::Select and Fn::Split to take out the part after the '/' from the ARN: + core.Fn.select(1, core.Fn.split('/', resource.ref)), + ); + this.exampleResourceArn = this.getResourceArnAttribute( + // A lot of the L1 classes have an 'attrArn' property - + // if yours does, use it here. + // However, if it doesn't, + // you can often formulate the ARN yourself, + // using the Stack.formatArn helper function. + // Here, we assume resource.ref returns the physical name of the resource. + core.Stack.of(this).formatArn(exampleResourceArnComponents(resource.ref)), + // always use the protected physicalName property for this second argument + exampleResourceArnComponents(this.physicalName)); + + // if a role wasn't passed, create one + const role = props.role || new iam.Role(this, 'Role', { + // of course, fill your correct service principal here + assumedBy: new iam.ServicePrincipal('cloudformation.amazonaws.com'), + }); + this.role = role; + // we need this to correctly implement the iam.IGrantable interface + this.grantPrincipal = role; + + // implement the ec2.IConnectable interface, + // by writing to the _connections field in ExampleResourceBase, + // if a VPC was passed in props + if (props.vpc) { + const securityGroups = (props.securityGroups ?? []).length === 0 + // no security groups were provided - create one + ? [new ec2.SecurityGroup(this, 'SecurityGroup', { + vpc: props.vpc, + })] + : props.securityGroups; + this._connections = new ec2.Connections({ securityGroups }); + + // this is how you would use the VPC inputs to fill a subnetIds property of an L1: + new ec2.CfnVPCEndpoint(this, 'VpcEndpoint', { + vpcId: props.vpc.vpcId, + serviceName: 'ServiceName', + subnetIds: props.vpc.selectSubnets(props.vpcSubnets).subnetIds, + }); + } + + // this is how you apply the removal policy + resource.applyRemovalPolicy(props.removalPolicy, { + // this is the default to apply if props.removalPolicy is undefined + default: core.RemovalPolicy.RETAIN, + }); + } +} diff --git a/packages/@aws-cdk/example-construct-library/lib/index.ts b/packages/@aws-cdk/example-construct-library/lib/index.ts new file mode 100644 index 0000000000000..7ab8c455fa2e9 --- /dev/null +++ b/packages/@aws-cdk/example-construct-library/lib/index.ts @@ -0,0 +1,9 @@ +// The index.ts files contains a list of files we want to +// include as part of the public API of this module. +// In general, all files including L2 classes will be listed here, +// while all files including only utility functions will be omitted from here. + +// obviously, the ExampleResource L2 should be exported +export * from './example-resource'; + +// notice that private/example-resource-common.ts is not exported! diff --git a/packages/@aws-cdk/example-construct-library/lib/private/example-resource-common.ts b/packages/@aws-cdk/example-construct-library/lib/private/example-resource-common.ts new file mode 100644 index 0000000000000..ec95e51fa4825 --- /dev/null +++ b/packages/@aws-cdk/example-construct-library/lib/private/example-resource-common.ts @@ -0,0 +1,18 @@ +import * as cdk from '@aws-cdk/core'; + +// This file contains utility functions used in the implementation of ExampleResource +// which we don't want to make part of the public API of this module +// (in fact, we can't, as JSII does not work for standalone functions!). +// So, while the functions are exported from this file, +// this file is not listed in index.ts, +// and so these functions are effectively 'module-private'. +// To make it clear that this file should not be exported, +// we place it in a subdirectory of lib called 'private'. + +export function exampleResourceArnComponents(exampleResourceName: string): cdk.ArnComponents { + return { + service: 'cloudformation', + resource: 'wait-condition', + resourceName: exampleResourceName, + }; +} diff --git a/packages/@aws-cdk/example-construct-library/package.json b/packages/@aws-cdk/example-construct-library/package.json new file mode 100644 index 0000000000000..31dd2522ddc41 --- /dev/null +++ b/packages/@aws-cdk/example-construct-library/package.json @@ -0,0 +1,97 @@ +{ + "name": "@aws-cdk/example-construct-library", + "private": true, + "version": "0.0.0", + "description": "An example CDK Construct Library that can serve as a template for creating new libraries", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "jsii": { + "outdir": "dist", + "targets": { + "java": { + "package": "software.amazon.awscdk.example.construct.library", + "maven": { + "groupId": "software.amazon.awscdk", + "artifactId": "cdk-example-construct-library" + } + }, + "dotnet": { + "namespace": "Amazon.CDK.Example.Construct.Library", + "packageId": "Amazon.CDK.Example.Construct.Library", + "signAssembly": true, + "assemblyOriginatorKeyFile": "../../key.snk", + "iconUrl": "https://raw.githubusercontent.com/aws/aws-cdk/master/logo/default-256-dark.png" + }, + "python": { + "distName": "aws-cdk.example-construct-library", + "module": "aws_cdk.example_construct_library" + } + } + }, + "repository": { + "type": "git", + "url": "https://github.com/aws/aws-cdk.git", + "directory": "packages/@aws-cdk/example-construct-library" + }, + "scripts": { + "build": "cdk-build", + "watch": "cdk-watch", + "lint": "cdk-lint", + "test": "cdk-test", + "integ": "cdk-integ", + "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" + }, + "keywords": [ + "aws", + "cdk", + "example", + "construct", + "library" + ], + "author": { + "name": "Amazon Web Services", + "url": "https://aws.amazon.com", + "organization": true + }, + "license": "Apache-2.0", + "devDependencies": { + "@aws-cdk/assert": "0.0.0", + "cdk-build-tools": "0.0.0", + "cdk-integ-tools": "0.0.0", + "jest": "^25.5.3", + "pkglint": "0.0.0" + }, + "dependencies": { + "@aws-cdk/aws-cloudwatch": "0.0.0", + "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-events": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/core": "0.0.0", + "constructs": "^3.0.2" + }, + "homepage": "https://github.com/aws/aws-cdk", + "peerDependencies": { + "@aws-cdk/aws-cloudwatch": "0.0.0", + "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-events": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/core": "0.0.0", + "constructs": "^3.0.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "jest": {}, + "stability": "experimental", + "maturity": "experimental", + "awscdkio": { + "announce": false + } +} diff --git a/packages/@aws-cdk/example-construct-library/test/example-resource.test.ts b/packages/@aws-cdk/example-construct-library/test/example-resource.test.ts new file mode 100644 index 0000000000000..5dcab5f1a8288 --- /dev/null +++ b/packages/@aws-cdk/example-construct-library/test/example-resource.test.ts @@ -0,0 +1,204 @@ +/* + * We write unit tests using the Jest framework + * (some modules might still use NodeUnit, + * but it's considered legacy, and we want to migrate to Jest). + */ + +// import the various CDK assertion helpers +import { ABSENT, ResourcePart } from '@aws-cdk/assert'; +// always import our Jest-specific helpers +import '@aws-cdk/assert/jest'; + +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as iam from '@aws-cdk/aws-iam'; +import * as core from '@aws-cdk/core'; +// Always import the module you're testing qualified - +// don't import individual classes from it! +// Importing it qualified tests whether everything that needs to be exported +// from the module is. +import * as er from '../lib'; + +/* We allow quotes in the object keys used for CloudFormation template assertions */ +// tslint:disable:object-literal-key-quotes + +describe('Example Resource', () => { + let stack: core.Stack; + + beforeEach(() => { + // try to factor out as much boilerplate test setup to before methods - + // makes the tests much more readable + stack = new core.Stack(); + }); + + describe('created with default properties', () => { + let exampleResource: er.IExampleResource; + + beforeEach(() => { + exampleResource = new er.ExampleResource(stack, 'ExampleResource'); + }); + + test('creates a CFN WaitConditionHandle resource', () => { + // you can simply assert that a resource of a given type + // was generated in the resulting template + expect(stack).toHaveResource('AWS::CloudFormation::WaitConditionHandle'); + }); + + describe('creates a CFN WaitCondition resource', () => { + test('with count = 0 and timeout = 10', () => { + // you can also assert the properties of the resulting resource + expect(stack).toHaveResource('AWS::CloudFormation::WaitCondition', { + 'Count': 0, + 'Timeout': '10', + 'Handle': { + // Don't be afraid of using the generated logical IDs in your tests! + // While they look random, they are actually only dependent on the + // path constructs have in the tree. + // Since changing logical IDs as the library changes actually causes + // problems for their customers (their CloudFormation resources will be replaced), + // it's good for the unit tests to verify that the logical IDs are stable. + 'Ref': 'ExampleResourceWaitConditionHandle9C53A8D3', + }, + // this is how you can check a given property is _not_ set + 'RandomProperty': ABSENT, + }); + }); + + test('with retention policy = Retain', () => { + // haveResource asserts _all_ properties of a resource, + // while haveResourceLike only those that you provide + expect(stack).toHaveResourceLike('AWS::CloudFormation::WaitCondition', { + 'DeletionPolicy': 'Retain', + 'UpdateReplacePolicy': 'Retain', + // by default, haveResource and haveResourceLike only assert the properties of a resource - + // here's how you make them look at the entire resource definition + }, ResourcePart.CompleteDefinition); + }); + }); + + test('returns true from addToResourcePolicy', () => { + const result = exampleResource.addToRolePolicy(new iam.PolicyStatement({ + actions: ['kms:*'], + resources: ['*'], + })); + + expect(result).toBe(true); + }); + + test('correctly adds s3:Get* permissions when grantRead() is called', () => { + const role = new iam.Role(stack, 'Role', { + assumedBy: new iam.AnyPrincipal(), + }); + + exampleResource.grantRead(role); + + expect(stack).toHaveResourceLike('AWS::IAM::Policy', { + 'PolicyDocument': { + 'Statement': [ + { + 'Action': 's3:Get*', + 'Resource': { + 'Fn::Join': ['', [ + 'arn:', + { 'Ref': 'AWS::Partition' }, + ':cloudformation:', + { 'Ref': 'AWS::Region' }, + ':', + { 'Ref': 'AWS::AccountId' }, + ':wait-condition/', + { 'Ref': 'ExampleResourceAC53F4AE' }, + ]], + }, + }, + ], + }, + }); + }); + }); + + describe('created with a VPC', () => { + let exampleResource: er.IExampleResource; + let vpc: ec2.IVpc; + + beforeEach(() => { + vpc = new ec2.Vpc(stack, 'Vpc'); + exampleResource = new er.ExampleResource(stack, 'ExampleResource', { + vpc, + }); + }); + + test('allows manipulating its connections object', () => { + exampleResource.connections.allowToAnyIpv4(ec2.Port.allTcp()); + }); + + test('correctly fills out the subnetIds property of the created VPC endpoint', () => { + expect(stack).toHaveResourceLike('AWS::EC2::VPCEndpoint', { + 'SubnetIds': [ + { 'Ref': 'VpcPrivateSubnet1Subnet536B997A' }, + { 'Ref': 'VpcPrivateSubnet2Subnet3788AAA1' }, + ], + }); + }); + }); + + describe('imported by name', () => { + let exampleResource: er.IExampleResource; + + beforeEach(() => { + exampleResource = er.ExampleResource.fromExampleResourceName(stack, 'ExampleResource', + 'my-example-resource-name'); + }); + + test('has the same name as it was imported with', () => { + expect(exampleResource.exampleResourceName).toEqual('my-example-resource-name'); + }); + + test('renders the correct ARN for Example Resource', () => { + // We can't simply compare the value we get from exampleResource.exampleResourceArn, + // as it will contain unresolved late-bound values + // (what we in the CDK call Tokens). + // So, use a utility method on Stack that allows you to resolve those Tokens + // into their correct values. + const arn = stack.resolve(exampleResource.exampleResourceArn); + expect(arn).toEqual({ + 'Fn::Join': ['', [ + 'arn:', + { 'Ref': 'AWS::Partition' }, + ':cloudformation:', + { 'Ref': 'AWS::Region' }, + ':', + { 'Ref': 'AWS::AccountId' }, + ':wait-condition/my-example-resource-name', + ]], + }); + }); + + test('returns false from addToResourcePolicy', () => { + const result = exampleResource.addToRolePolicy(new iam.PolicyStatement({ + actions: ['kms:*'], + resources: ['*'], + })); + + expect(result).toEqual(false); + }); + }); + + test('cannot be created with a physical name containing illegal characters', () => { + // this is how we write tests that expect an exception to be thrown + expect(() => { + new er.ExampleResource(stack, 'ExampleResource', { + waitConditionHandleName: 'a-1234', + }); + // it's not enough to know an exception was thrown - + // we have to verify that its message is what we expected + }).toThrow(/waitConditionHandleName must be non-empty and contain only letters and underscores, got: 'a-1234'/); + }); + + test('does not fail validation if the physical name is a late-bound value', () => { + const parameter = new core.CfnParameter(stack, 'Parameter'); + + // no assertion necessary - the lack of an exception being thrown is all we need in this case + new er.ExampleResource(stack, 'ExampleResource', { + waitConditionHandleName: parameter.valueAsString, + }); + }); +}); diff --git a/packages/@aws-cdk/example-construct-library/test/integ.example-resource.expected.json b/packages/@aws-cdk/example-construct-library/test/integ.example-resource.expected.json new file mode 100644 index 0000000000000..c9e2f4bc4db5a --- /dev/null +++ b/packages/@aws-cdk/example-construct-library/test/integ.example-resource.expected.json @@ -0,0 +1,36 @@ +{ + "Resources": { + "ExampleResourceWaitConditionHandle9C53A8D3": { + "Type": "AWS::CloudFormation::WaitConditionHandle" + }, + "ExampleResourceAC53F4AE": { + "Type": "AWS::CloudFormation::WaitCondition", + "Properties": { + "Count": 0, + "Handle": { + "Ref": "ExampleResourceWaitConditionHandle9C53A8D3" + }, + "Timeout": "10" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ExampleResourceRole0533653E": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "cloudformation.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/example-construct-library/test/integ.example-resource.ts b/packages/@aws-cdk/example-construct-library/test/integ.example-resource.ts new file mode 100644 index 0000000000000..93d082da24989 --- /dev/null +++ b/packages/@aws-cdk/example-construct-library/test/integ.example-resource.ts @@ -0,0 +1,25 @@ +/* + * Our integration tests act as snapshot tests to make sure the rendered template is stable. + * If any changes to the result are required, + * you need to perform an actual CloudFormation deployment of this application, + * and, if it is successful, a new snapshot will be written out. + * + * For more information on CDK integ tests, + * see the main CONTRIBUTING.md file. + */ + +import * as core from '@aws-cdk/core'; +// as in unit tests, we use a qualified import, +// not bring in individual classes +import * as er from '../lib'; + +const app = new core.App(); + +const stack = new core.Stack(app, 'ExampleResourceIntegTestStack'); + +new er.ExampleResource(stack, 'ExampleResource', { + // we don't want to leave trash in the account after running the deployment of this + removalPolicy: core.RemovalPolicy.DESTROY, +}); + +app.synth(); diff --git a/packages/decdk/deps.js b/packages/decdk/deps.js index 3435c5e6a0acf..e6087c7163690 100644 --- a/packages/decdk/deps.js +++ b/packages/decdk/deps.js @@ -31,6 +31,10 @@ for (const dir of modules) { delete deps[meta.name]; continue; } + // skip private packages + if (meta.private) { + continue; + } if (!exists) { console.error(`missing dependency: ${meta.name}`); diff --git a/packages/monocdk-experiment/deps.js b/packages/monocdk-experiment/deps.js index 034bcc33944c3..61215dd7c84fa 100644 --- a/packages/monocdk-experiment/deps.js +++ b/packages/monocdk-experiment/deps.js @@ -30,6 +30,10 @@ for (const dir of modules) { delete pkgDevDeps[meta.name]; continue; } + // skip private packages + if (meta.private) { + continue; + } if (!exists) { console.error(`missing dependency: ${meta.name}`);