From 5054eefd7c682b0745aec3c9fd4f6c7020323c2f Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Thu, 27 Sep 2018 23:25:49 +0300 Subject: [PATCH] feat(core): resource overrides (escape hatch) (#784) Adds capabilities and documentation to allow users to add overrides to synthesized resource definitions in case of gaps in L2s. - resource.addOverride(path, value) - resource.addPropertyOverride(propertyPath, value) - resource.addDeletionOverride(p, v) - resource.addPropertyDeletionOVerride(pp, v) - xxxResource.propertyOverrides (of type XxxResourceProps) Documented under the AWS Construct Library topic. Fixes #606 --- docs/src/aws-construct-lib.rst | 275 +++++++++++++++++- .../resource-overrides/cdk.json | 22 ++ .../resource-overrides/index.ts | 118 ++++++++ .../aws-cloudformation/lib/custom-resource.ts | 4 +- .../cdk/lib/cloudformation/resource.ts | 131 ++++++++- .../cdk/test/cloudformation/test.resource.ts | 241 +++++++++++++++ tools/cfn2ts/lib/codegen.ts | 8 +- 7 files changed, 783 insertions(+), 16 deletions(-) create mode 100644 examples/cdk-examples-typescript/resource-overrides/cdk.json create mode 100644 examples/cdk-examples-typescript/resource-overrides/index.ts diff --git a/docs/src/aws-construct-lib.rst b/docs/src/aws-construct-lib.rst index 118b17393a804..13430ad08eeb5 100644 --- a/docs/src/aws-construct-lib.rst +++ b/docs/src/aws-construct-lib.rst @@ -103,9 +103,274 @@ were part of your app. .. _cloudformation_layer: -AWS CloudFormation Layer -======================== +Access the AWS CloudFormation Layer +=================================== + +This topic discusses ways to directly modify the underlying CloudFormation +resources at the AWS Construct Library. We also call this technique an "escape +hatch", as it allows users to "escape" from the abstraction boundary defined by +the AWS Construct and patch the underlying resources. + +.. important:: + + **We do not recommend this method, as it breaks the abstraction layer and + might produce unexpected results**. + + Furthermore, the internal implementation of an AWS construct is not part of + the API compatibility guarantees that we can make. This means that updates to + the construct library may break your code without a major version bump. + +AWS constructs, such as :py:class:`Topic <@aws-cdk/aws-sns.Topic>`, encapsulate +one or more AWS CloudFormation resources behind their APIs. These resources are +also represented as constructs under the ``cloudformation`` namespace in each +library. For example, the :py:class:`@aws-cdk/aws-s3.Bucket` construct +encapsulates the :py:class:`@aws-cdk/aws-s3.cloudformation.BucketResource`. When +a stack that includes an AWS construct is synthesized, the CloudFormation +definition of the underlying resources are included in the resulting template. + +Eventually, the APIs provided by AWS constructs are expected to support all the +services and capabilities offered by AWS, but we are aware that the library +still has many gaps both at the service level (some services don't have any +constructs yet) and at the resource level (an AWS construct exists, but some +features are missing). + +.. note:: + + If you encounter a missing capability in the AWS Construct Library, whether + it is an entire library, a specific resource or a feature, + `raise an issue `_ on GitHub, + and letting us know. + +This topic covers the following use cases: + +- How to access the low-level CloudFormation resources encapsulated by an AWS construct +- How to specify resource options such as metadata, dependencies on resources +- How to add overrides to a CloudFormation resource and property definitions +- How to directly define low-level CloudFormation resources without an AWS construct + +You can also find more information on how to work directly with the AWS +CloudFormation layer under :py:doc:`cloudformation`. + +Accessing Low-level Resources +----------------------------- + +You can use :py:meth:`construct.findChild(id) <@aws-cdk/cdk.Construct.findChild>` +to access any child of this construct by its construct ID. By convention, the "main" +resource of any AWS Construct is called ``"Resource"``. + +The following example shows how to access the underlying S3 bucket resource +given an :py:class:`s3.Bucket <@aws-cdk/s3.Bucket>` construct: + +.. code-block:: ts + + // let's create an AWS bucket construct + const bucket = new s3.Bucket(this, 'MyBucket'); + + // we use our knowledge that the main construct is called "Resource" and + // that it's actual type is s3.cloudformation.BucketResource; const + const bucketResource = bucket.findResource('Resource') as s3.cloudformation.BucketResource; + +At this point, ``bucketResource`` represents the low-level CloudFormation resource of type +:py:class:`s3.cloudformation.BucketResource <@aws-cdk/aws-s3.cloudformation.BucketResource>` +encapsulated by our bucket. + +:py:meth:`construct.findChild(id) <@aws-cdk/cdk.Construct.findChild>` will fail +if the child could not be located, which means that if the underlying |l2| changes +the IDs or structure for some reason, synthesis fails. + +It is also possible to use :py:meth:`construct.children <@aws-cdk/cdk.Construct.children>` for more +advanced queries. For example, we can look for a child that has a certain CloudFormation resource +type: + +.. code-block:: ts + + const bucketResource = + bucket.children.find(c => (c as cdk.Resource).resourceType === 'AWS::S3::Bucket') + as s3.cloudformation.BucketResource; + +From that point, users are interacting with CloudFormation resource classes +(which extend :py:class:`cdk.Resource <@aws-cdk/cdk.Resource>`), so we will look +into how to use their APIs in order to modify the behavior of the AWS construct +at hand. + +Resource Options +---------------- + +:py:class:`cdk.Resource <@aws-cdk/cdk.Resource>` has a few facilities for +setting resource options such as ``Metadata``, ``DependsOn``, etc. + +For example, this code: + +.. code-block:: ts + + const bucketResource = bucket.findChild('Resource') as s3.cloudformation.BucketResource; + + bucketResource.options.metadata = { MetadataKey: 'MetadataValue' }; + bucketResource.options.updatePolicy = { + autoScalingRollingUpdate: { + pauseTime: '390' + } + }; + + bucketResource.addDependency(otherBucket.findChild('Resource') as cdk.Resource); + +Synthesizes the following template: + +.. code-block:: json + + { + "Type": "AWS::S3::Bucket", + "DependsOn": [ "Other34654A52" ], + "UpdatePolicy": { + "AutoScalingRollingUpdate": { + "PauseTime": "390" + } + }, + "Metadata": { + "MetadataKey": "MetadataValue" + } + } + +Overriding Resource Properties +------------------------------ + +Each low-level resource in the CDK has a strongly-typed property called +``propertyOverrides``. It allows users to apply overrides that adhere to the +CloudFormation schema of the resource, and use code-completion and +type-checking. + +You will normally use this mechanism when a certain feature is available at the +CloudFormation layer but is not exposed by the AWS Construct. + +The following example sets a bucket's analytics configuration: + +.. code-block:: ts + + bucketResource.propertyOverrides.analyticsConfigurations = [ + { + id: 'config1', + storageClassAnalysis: { + dataExport: { + outputSchemaVersion: '1', + destination: { + format: 'html', + bucketArn: otherBucket.bucketArn // use tokens freely + } + } + } + } + ]; + +Raw Overrides +------------- + +In cases the strongly-typed overrides are not sufficient, or, for example, if +the schema defined in CloudFormation is not up-to-date, the method +:py:meth:`cdk.Resource.addOverride(path, value) <@aws-cdk/cdk.Resource.addOverride>` +can be used to define an override that will by applied to the resource +definition during synthesis. + +For example: + +.. code-block:: ts + + // define an override at the resource definition root, you can even modify the "Type" + // of the resource if needed. + bucketResource.addOverride('Type', 'AWS::S3::SpecialBucket'); + + // define an override for a property (both are equivalent operations): + bucketResource.addPropertyOverride('VersioningConfiguration.Status', 'NewStatus'); + bucketResource.addOverride('Properties.VersioningConfiguration.Status', 'NewStatus'); + + // use dot-notation to define overrides in complex structures which will be merged + // with the values set by the higher-level construct + bucketResource.addPropertyOverride('LoggingConfiguration.DestinationBucketName', otherBucket.bucketName); + + // it is also possible to assign a `null` value + bucketResource.addPropertyOverride('Foo.Bar', null); + +Synthesizes to: + +.. code-block:: json + + { + "Type": "AWS::S3::SpecialBucket", + "Properties": { + "Foo": { + "Bar": null + }, + "VersioningConfiguration": { + "Status": "NewStatus" + }, + "LoggingConfiguration": { + "DestinationBucketName": { + "Ref": "Other34654A52" + } + } + } + } + +Use ``undefined``, :py:meth:`cdk.Resource.addDeletionOverride <@aws-cdk/cdk.Resource.addDeletionOverride>` +or :py:meth:`cdk.Resource.addPropertyDeletionOverride <@aws-cdk/cdk.Resource.addPropertyDeletionOverride>` +to delete values: + +.. code-block:: ts + + const bucket = new s3.Bucket(this, 'MyBucket', { + versioned: true, + encryption: s3.BucketEncryption.KmsManaged + }); + + const bucketResource = bucket.findChild('Resource') as s3.cloudformation.BucketResource; + bucketResource.addPropertyOverride('BucketEncryption.ServerSideEncryptionConfiguration.0.EncryptEverythingAndAlways', true); + bucketResource.addPropertyDeletionOverride('BucketEncryption.ServerSideEncryptionConfiguration.0.ServerSideEncryptionByDefault'); + +Synthesizes to: + +.. code-block:: json + + "MyBucketF68F3FF0": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "EncryptEverythingAndAlways": true + } + ] + }, + "VersioningConfiguration": { + "Status": "Enabled" + } + } + } + +Directly Defining CloudFormation Resources +------------------------------------------- + +It is also possible to explicitly define CloudFormation resources in your stack. +To that end, instantiate one of the constructs under the ``cloudformation`` +namespace of the dedicated library. + +.. code-block:: ts + + new s3.cloudformation.BucketResource(this, 'MyBucket', { + analyticsConfigurations: [ + // ... + ] + }); + +In the rare case where you want to define a resource that doesn't have a +corresponding ``cloudformation`` class (such as a new resource that was not yet +published in the CloudFormation resource specification), you can instantiate the +:py:class:`cdk.Resource <@aws-cdk/cdk.Resource>` object: + +.. code-block:: ts + + new cdk.Resource(this, 'MyBucket', { + type: 'AWS::S3::Bucket', + properties: { + AnalyticsConfiguration: [ /* ... */ ] // note the PascalCase here + } + }); -Every module in the AWS Construct Library includes a ``cloudformation`` namespace which contains -low-level constructs which represent the low-level AWS CloudFormation semantics of this service. -See :py:doc:`cloudformation` for details. diff --git a/examples/cdk-examples-typescript/resource-overrides/cdk.json b/examples/cdk-examples-typescript/resource-overrides/cdk.json new file mode 100644 index 0000000000000..34ce3ae7cb680 --- /dev/null +++ b/examples/cdk-examples-typescript/resource-overrides/cdk.json @@ -0,0 +1,22 @@ +{ + "app": "node index", + "context": { + "availability-zones:993655754359:us-east-1": [ + "us-east-1a", + "us-east-1b", + "us-east-1c", + "us-east-1d", + "us-east-1e", + "us-east-1f" + ], + "availability-zones:585695036304:us-east-1": [ + "us-east-1a", + "us-east-1b", + "us-east-1c", + "us-east-1d", + "us-east-1e", + "us-east-1f" + ], + "ssm:585695036304:us-east-1:/aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-gp2": "ami-0ff8a91507f77f867" + } +} diff --git a/examples/cdk-examples-typescript/resource-overrides/index.ts b/examples/cdk-examples-typescript/resource-overrides/index.ts new file mode 100644 index 0000000000000..8d7b786c89022 --- /dev/null +++ b/examples/cdk-examples-typescript/resource-overrides/index.ts @@ -0,0 +1,118 @@ +import autoscaling = require('@aws-cdk/aws-autoscaling'); +import ec2 = require('@aws-cdk/aws-ec2'); +import s3 = require('@aws-cdk/aws-s3'); +import cdk = require('@aws-cdk/cdk'); +import assert = require('assert'); + +class ResourceOverridesExample extends cdk.Stack { + constructor(parent: cdk.App, id: string) { + super(parent, id); + + const otherBucket = new s3.Bucket(this, 'Other'); + + const bucket = new s3.Bucket(this, 'MyBucket', { + versioned: true, + encryption: s3.BucketEncryption.KmsManaged + }); + + const bucketResource2 = bucket.findChild('Resource') as s3.cloudformation.BucketResource; + bucketResource2.addPropertyOverride('BucketEncryption.ServerSideEncryptionConfiguration.0.EncryptEverythingAndAlways', true); + bucketResource2.addPropertyDeletionOverride('BucketEncryption.ServerSideEncryptionConfiguration.0.ServerSideEncryptionByDefault'); + + return; + + // + // Accessing the L1 bucket resource from an L2 bucket + // + + const bucketResource = bucket.findChild('Resource') as s3.cloudformation.BucketResource; + const anotherWay = bucket.children.find(c => (c as cdk.Resource).resourceType === 'AWS::S3::Bucket') as s3.cloudformation.BucketResource; + assert.equal(bucketResource, anotherWay); + + // + // This is how to specify resource options such as dependencies, metadata, update policy + // + + bucketResource.addDependency(otherBucket.findChild('Resource') as cdk.Resource); + bucketResource.options.metadata = { MetadataKey: 'MetadataValue' }; + bucketResource.options.updatePolicy = { + autoScalingRollingUpdate: { + pauseTime: '390' + } + }; + + // + // This is how to specify "raw" overrides at the __resource__ level + // + + bucketResource.addOverride('Type', 'AWS::S3::Bucketeer'); // even "Type" can be overridden + bucketResource.addOverride('Transform', 'Boom'); + bucketResource.addOverride('Properties.CorsConfiguration', { + Custom: 123, + Bar: [ 'A', 'B' ] + }); + + // addPropertyOverrides simply allows you to omit the "Properties." prefix + bucketResource.addPropertyOverride('VersioningConfiguration.Status', 'NewStatus'); + bucketResource.addPropertyOverride('Foo', null); + bucketResource.addPropertyOverride('Token', otherBucket.bucketArn); // use tokens + bucketResource.addPropertyOverride('LoggingConfiguration.DestinationBucketName', otherBucket.bucketName); + + // + // It is also possible to request a deletion of a value by either assigning + // `undefined` (in supported languages) or use the `addDeletionOverride` method + // + + bucketResource.addDeletionOverride('Metadata'); + bucketResource.addPropertyDeletionOverride('CorsConfiguration.Bar'); + + // + // It is also possible to specify overrides via a strong-typed property + // bag called `propertyOverrides` + // + + bucketResource.propertyOverrides.analyticsConfigurations = [ + { + id: 'config1', + storageClassAnalysis: { + dataExport: { + outputSchemaVersion: '1', + destination: { + format: 'html', + bucketArn: otherBucket.bucketArn // use tokens freely + } + } + } + } + ]; + + bucketResource.propertyOverrides.corsConfiguration = { + corsRules: [ + { + allowedMethods: [ 'GET' ], + allowedOrigins: [ '*' ] + } + ] + }; + + const vpc = new ec2.VpcNetwork(this, 'VPC', { maxAZs: 1 }); + const asg = new autoscaling.AutoScalingGroup(this, 'ASG', { + instanceType: new ec2.InstanceTypePair(ec2.InstanceClass.M4, ec2.InstanceSize.XLarge), + machineImage: new ec2.AmazonLinuxImage(), + vpc + }); + + // + // The default child resource is called `Resource`, but secondary resources, such as + // an Auto Scaling Group's launch configuration might have a different ID. You will likely + // need to consule the codebase or use the `.map.find` method above + // + + const lc = asg.findChild('LaunchConfig') as autoscaling.cloudformation.LaunchConfigurationResource; + lc.addPropertyOverride('Foo.Bar', 'Hello'); + } +} + +const app = new cdk.App(process.argv); +new ResourceOverridesExample(app, 'resource-overrides'); +process.stdout.write(app.run()); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudformation/lib/custom-resource.ts b/packages/@aws-cdk/aws-cloudformation/lib/custom-resource.ts index 69a85dd404dfc..32854366bc3ab 100644 --- a/packages/@aws-cdk/aws-cloudformation/lib/custom-resource.ts +++ b/packages/@aws-cdk/aws-cloudformation/lib/custom-resource.ts @@ -62,8 +62,8 @@ export class CustomResource extends cloudformation.CustomResource { /** * Override renderProperties to mix in the user-defined properties */ - protected renderProperties(): {[key: string]: any} { - const props = super.renderProperties(); + protected renderProperties(properties: any): {[key: string]: any} { + const props = super.renderProperties(properties); return Object.assign(props, uppercaseProperties(this.userProperties || {})); } diff --git a/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts b/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts index 8cfea4b015f94..a8c90de6e6348 100644 --- a/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts +++ b/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts @@ -48,10 +48,29 @@ export class Resource extends Referenceable { public readonly resourceType: string; /** - * AWS resource properties + * AWS resource property overrides. + * + * During synthesis, the method "renderProperties(this.overrides)" is called + * with this object, and merged on top of the output of + * "renderProperties(this.properties)". + * + * Derived classes should expose a strongly-typed version of this object as + * a public property called `propertyOverrides`. + */ + protected readonly untypedPropertyOverrides: any = { }; + + /** + * AWS resource properties. + * + * This object is rendered via a call to "renderProperties(this.properties)". */ protected readonly properties: any; + /** + * An object to be merged on top of the entire resource definition. + */ + private readonly rawOverrides: any = { }; + private dependsOn = new Array(); /** @@ -93,23 +112,85 @@ export class Resource extends Referenceable { this.dependsOn.push(...other); } + /** + * Adds an override to the synthesized CloudFormation resource. To add a + * property override, either use `addPropertyOverride` or prefix `path` with + * "Properties." (i.e. `Properties.TopicName`). + * + * @param path The path of the property, you can use dot notation to + * override values in complex types. Any intermdediate keys + * will be created as needed. + * @param value The value. Could be primitive or complex. + */ + public addOverride(path: string, value: any) { + const parts = path.split('.'); + let curr: any = this.rawOverrides; + + while (parts.length > 1) { + const key = parts.shift()!; + + // if we can't recurse further or the previous value is not an + // object overwrite it with an object. + const isObject = curr[key] != null && typeof(curr[key]) === 'object' && !Array.isArray(curr[key]); + if (!isObject) { + curr[key] = { }; + } + + curr = curr[key]; + } + + const lastKey = parts.shift()!; + curr[lastKey] = value; + } + + /** + * Syntactic sugar for `addOverride(path, undefined)`. + * @param path The path of the value to delete + */ + public addDeletionOverride(path: string) { + this.addOverride(path, undefined); + } + + /** + * Adds an override to a resource property. + * + * Syntactic sugar for `addOverride("Properties.<...>", value)`. + * + * @param propertyPath The path of the property + * @param value The value + */ + public addPropertyOverride(propertyPath: string, value: any) { + this.addOverride(`Properties.${propertyPath}`, value); + } + + /** + * Adds an override that deletes the value of a property from the resource definition. + * @param propertyPath The path to the property. + */ + public addPropertyDeletionOverride(propertyPath: string) { + this.addPropertyOverride(propertyPath, undefined); + } + /** * Emits CloudFormation for this resource. */ public toCloudFormation(): object { try { + // merge property overrides onto properties and then render (and validate). + const properties = this.renderProperties(deepMerge(this.properties || { }, this.untypedPropertyOverrides)); + return { Resources: { - [this.logicalId]: { + [this.logicalId]: deepMerge({ Type: this.resourceType, - Properties: ignoreEmpty(this.renderProperties()), + Properties: ignoreEmpty(properties), DependsOn: ignoreEmpty(this.renderDependsOn()), CreationPolicy: capitalizePropertyNames(this.options.creationPolicy), UpdatePolicy: capitalizePropertyNames(this.options.updatePolicy), DeletionPolicy: capitalizePropertyNames(this.options.deletionPolicy), Metadata: ignoreEmpty(this.options.metadata), Condition: this.options.condition && this.options.condition.logicalId - } + }, this.rawOverrides) } }; } catch (e) { @@ -124,9 +205,8 @@ export class Resource extends Referenceable { } } - protected renderProperties(): { [key: string]: any } { - // FIXME: default implementation is not great, it should throw, but it avoids breaking all unit tests for now. - return this.properties; + protected renderProperties(properties: any): { [key: string]: any } { + return properties; } private renderDependsOn() { @@ -192,3 +272,40 @@ export interface ResourceOptions { */ metadata?: { [key: string]: any }; } + +/** + * Merges `source` into `target`, overriding any existing values. + * `null`s will cause a value to be deleted. + */ +export function deepMerge(target: any, source: any) { + if (typeof(source) !== 'object' || typeof(target) !== 'object') { + throw new Error(`Invalid usage. Both source (${JSON.stringify(source)}) and target (${JSON.stringify(target)}) must be objects`); + } + + for (const key of Object.keys(source)) { + const value = source[key]; + if (typeof(value) === 'object' && value != null && !Array.isArray(value)) { + // if the value at the target is not an object, override it with an + // object so we can continue the recursion + if (typeof(target[key]) !== 'object') { + target[key] = { }; + } + + deepMerge(target[key], value); + + // if the result of the merge is an empty object, it's because the + // eventual value we assigned is `undefined`, and there are no + // sibling concrete values alongside, so we can delete this tree. + const output = target[key]; + if (typeof(output) === 'object' && Object.keys(output).length === 0) { + delete target[key]; + } + } else if (value === undefined) { + delete target[key]; + } else { + target[key] = value; + } + } + + return target; +} diff --git a/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts b/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts index 67ff2e1c304be..56421e9fc6b02 100644 --- a/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts +++ b/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts @@ -347,6 +347,229 @@ export = { test.deepEqual(resolve(r.ref), { Ref: 'MyResource' }); test.done(); + }, + + 'overrides': { + 'addOverride(p, v) allows assigning arbitrary values to synthesized resource definitions'(test: Test) { + // GIVEN + const stack = new Stack(); + const r = new Resource(stack, 'MyResource', { type: 'AWS::Resource::Type' }); + + // WHEN + r.addOverride('Type', 'YouCanEvenOverrideTheType'); + r.addOverride('Metadata', { Key: 12 }); + r.addOverride('Use.Dot.Notation', 'To create subtrees'); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'YouCanEvenOverrideTheType', + Use: { Dot: { Notation: 'To create subtrees' } }, + Metadata: { Key: 12 } } } }); + + test.done(); + }, + + 'addOverride(p, null) will assign an "null" value'(test: Test) { + // GIVEN + const stack = new Stack(); + + const r = new Resource(stack, 'MyResource', { + type: 'AWS::Resource::Type', + properties: { + Hello: { + World: { + Value1: 'Hello', + Value2: 129, + } + } + } + }); + + // WHEN + r.addOverride('Properties.Hello.World.Value2', null); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Value1: 'Hello', Value2: null } } } } } }); + + test.done(); + }, + + 'addOverride(p, undefined) can be used to delete a value'(test: Test) { + // GIVEN + const stack = new Stack(); + + const r = new Resource(stack, 'MyResource', { + type: 'AWS::Resource::Type', + properties: { + Hello: { + World: { + Value1: 'Hello', + Value2: 129, + } + } + } + }); + + // WHEN + r.addOverride('Properties.Hello.World.Value2', undefined); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Value1: 'Hello' } } } } } }); + + test.done(); + }, + + 'addOverride(p, undefined) will not create empty trees'(test: Test) { + // GIVEN + const stack = new Stack(); + + const r = new Resource(stack, 'MyResource', { type: 'AWS::Resource::Type' }); + + // WHEN + r.addPropertyOverride('Tree.Exists', 42); + r.addPropertyOverride('Tree.Does.Not.Exist', undefined); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: { Tree: { Exists: 42 } } } } }); + + test.done(); + }, + + 'addDeletionOverride(p) and addPropertyDeletionOverride(pp) are sugar `undefined`'(test: Test) { + // GIVEN + const stack = new Stack(); + + const r = new Resource(stack, 'MyResource', { + type: 'AWS::Resource::Type', + properties: { + Hello: { + World: { + Value1: 'Hello', + Value2: 129, + Value3: [ 'foo', 'bar' ] + } + } + } + }); + + // WHEN + r.addDeletionOverride('Properties.Hello.World.Value2'); + r.addPropertyDeletionOverride('Hello.World.Value3'); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Value1: 'Hello' } } } } } }); + + test.done(); + }, + + 'addOverride(p, v) will overwrite any non-objects along the path'(test: Test) { + // GIVEN + const stack = new Stack(); + const r = new Resource(stack, 'MyResource', { + type: 'AWS::Resource::Type', + properties: { + Hello: { + World: 42 + } + } + }); + + // WHEN + r.addOverride('Properties.Override1', [ 'Hello', 123 ]); + r.addOverride('Properties.Override1.Override2', { Heyy: [ 1 ] }); + r.addOverride('Properties.Hello.World.Foo.Bar', 42); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: + { Hello: { World: { Foo: { Bar: 42 } } }, + Override1: { + Override2: { Heyy: [ 1] } + } } } } }); + test.done(); + }, + + 'addPropertyOverride(pp, v) is a sugar for overriding properties'(test: Test) { + // GIVEN + const stack = new Stack(); + const r = new Resource(stack, 'MyResource', { + type: 'AWS::Resource::Type', + properties: { Hello: { World: 42 } } + }); + + // WHEN + r.addPropertyOverride('Hello.World', { Hey: 'Jude' }); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Hey: 'Jude' } } } } } }); + test.done(); + }, + + 'untypedPropertyOverrides': { + + 'can be used by derived classes to specify overrides before render()'(test: Test) { + const stack = new Stack(); + + const r = new CustomizableResource(stack, 'MyResource', { + prop1: 'foo' + }); + + r.setProperty('prop2', 'bar'); + + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'MyResourceType', + Properties: { PROP1: 'foo', PROP2: 'bar' } } } }); + test.done(); + }, + + '"properties" is undefined'(test: Test) { + const stack = new Stack(); + + const r = new CustomizableResource(stack, 'MyResource'); + + r.setProperty('prop3', 'zoo'); + + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'MyResourceType', + Properties: { PROP3: 'zoo' } } } }); + test.done(); + }, + + '"properties" is empty'(test: Test) { + const stack = new Stack(); + + const r = new CustomizableResource(stack, 'MyResource', { }); + + r.setProperty('prop3', 'zoo'); + r.setProperty('prop2', 'hey'); + + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'MyResourceType', + Properties: { PROP2: 'hey', PROP3: 'zoo' } } } }); + test.done(); + } + } } }; @@ -373,3 +596,21 @@ class Counter extends Resource { function withoutHash(logId: string) { return logId.substr(0, logId.length - 8); } + +class CustomizableResource extends Resource { + constructor(parent: Construct, id: string, props?: any) { + super(parent, id, { type: 'MyResourceType', properties: props }); + } + + public setProperty(key: string, value: any) { + this.untypedPropertyOverrides[key] = value; + } + + public renderProperties(properties: any) { + return { + PROP1: properties.prop1, + PROP2: properties.prop2, + PROP3: properties.prop3 + }; + } +} diff --git a/tools/cfn2ts/lib/codegen.ts b/tools/cfn2ts/lib/codegen.ts index 97af87153e3d5..ffd18a56ef6ec 100644 --- a/tools/cfn2ts/lib/codegen.ts +++ b/tools/cfn2ts/lib/codegen.ts @@ -322,8 +322,12 @@ export default class CodeGenerator { * Since resolve() deep-resolves, we only need to do this once. */ private emitCloudFormationPropertiesOverride(propsType: genspec.CodeName) { - this.code.openBlock('protected renderProperties(): { [key: string]: any } '); - this.code.line(`return ${genspec.cfnMapperName(propsType).fqn}(${CORE}.resolve(this.properties));`); + this.code.openBlock(`public get propertyOverrides(): ${propsType.className}`); + this.code.line(`return this.untypedPropertyOverrides;`); + this.code.closeBlock(); + + this.code.openBlock('protected renderProperties(properties: any): { [key: string]: any } '); + this.code.line(`return ${genspec.cfnMapperName(propsType).fqn}(${CORE}.resolve(properties));`); this.code.closeBlock(); }