packages/aws-cdk-lib/core/lib/cfn-resource.ts (330 lines of code) (raw):
import { Annotations } from './annotations';
import { CfnCondition } from './cfn-condition';
// import required to be here, otherwise causes a cycle when running the generated JavaScript
/* eslint-disable import/order */
import { CfnRefElement } from './cfn-element';
import { CfnCreationPolicy, CfnDeletionPolicy, CfnUpdatePolicy } from './cfn-resource-policy';
import { Construct, Node } from 'constructs';
import { addDependency, obtainDependencies, removeDependency } from './deps';
import { CfnReference } from './private/cfn-reference';
import { Reference } from './reference';
import { RemovalPolicy, RemovalPolicyOptions } from './removal-policy';
import { TagManager } from './tag-manager';
import { capitalizePropertyNames, ignoreEmpty, PostResolveToken } from './util';
import { FeatureFlags } from './feature-flags';
import { ResolutionTypeHint } from './type-hints';
import * as cxapi from '../../cx-api';
import { ValidationError } from './errors';
export interface CfnResourceProps {
/**
* CloudFormation resource type (e.g. `AWS::S3::Bucket`).
*/
readonly type: string;
/**
* Resource properties.
*
* @default - No resource properties.
*/
readonly properties?: { [name: string]: any };
}
/**
* Represents a CloudFormation resource.
*/
export class CfnResource extends CfnRefElement {
/**
* Check whether the given object is a CfnResource
*/
public static isCfnResource(this: void, x: any): x is CfnResource {
return x !== null && typeof(x) === 'object' && x.cfnResourceType !== undefined;
}
// MAINTAINERS NOTE: this class serves as the base class for the generated L1
// ("CFN") resources (such as `s3.CfnBucket`). These resources will have a
// property for each CloudFormation property of the resource. This means that
// if at some point in the future a property is introduced with a name similar
// to one of the properties here, it will be "masked" by the derived class. To
// that end, we prefix all properties in this class with `cfnXxx` with the
// hope to avoid those conflicts in the future.
/**
* Options for this resource, such as condition, update policy etc.
*/
public readonly cfnOptions: ICfnResourceOptions = {};
/**
* AWS resource type.
*/
public readonly cfnResourceType: string;
/**
* AWS CloudFormation resource properties.
*
* This object is returned via cfnProperties
* @internal
*/
protected readonly _cfnProperties: any;
/**
* An object to be merged on top of the entire resource definition.
*/
private readonly rawOverrides: any = {};
/**
* Logical IDs of dependencies.
*
* Is filled during prepare().
*/
private readonly dependsOn = new Set<CfnResource>();
/**
* Creates a resource construct.
* @param cfnResourceType The CloudFormation type of this resource (e.g. AWS::DynamoDB::Table)
*/
constructor(scope: Construct, id: string, props: CfnResourceProps) {
super(scope, id);
if (!props.type) {
throw new ValidationError('The `type` property is required', this);
}
this.cfnResourceType = props.type;
this._cfnProperties = props.properties || {};
// if aws:cdk:enable-path-metadata is set, embed the current construct's
// path in the CloudFormation template, so it will be possible to trace
// back to the actual construct path.
if (Node.of(this).tryGetContext(cxapi.PATH_METADATA_ENABLE_CONTEXT)) {
this.addMetadata(cxapi.PATH_METADATA_KEY, Node.of(this).path);
}
}
/**
* Sets the deletion policy of the resource based on the removal policy specified.
*
* The Removal Policy controls what happens to this resource when it stops
* being managed by CloudFormation, either because you've removed it from the
* CDK application or because you've made a change that requires the resource
* to be replaced.
*
* The resource can be deleted (`RemovalPolicy.DESTROY`), or left in your AWS
* account for data recovery and cleanup later (`RemovalPolicy.RETAIN`). In some
* cases, a snapshot can be taken of the resource prior to deletion
* (`RemovalPolicy.SNAPSHOT`). A list of resources that support this policy
* can be found in the following link:
*
* @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-attribute-deletionpolicy.html#aws-attribute-deletionpolicy-options
*/
public applyRemovalPolicy(policy: RemovalPolicy | undefined, options: RemovalPolicyOptions = {}) {
policy = policy || options.default || RemovalPolicy.RETAIN;
let deletionPolicy;
let updateReplacePolicy;
switch (policy) {
case RemovalPolicy.DESTROY:
deletionPolicy = CfnDeletionPolicy.DELETE;
updateReplacePolicy = CfnDeletionPolicy.DELETE;
break;
case RemovalPolicy.RETAIN:
deletionPolicy = CfnDeletionPolicy.RETAIN;
updateReplacePolicy = CfnDeletionPolicy.RETAIN;
break;
case RemovalPolicy.RETAIN_ON_UPDATE_OR_DELETE:
deletionPolicy = CfnDeletionPolicy.RETAIN_EXCEPT_ON_CREATE;
updateReplacePolicy = CfnDeletionPolicy.RETAIN;
break;
case RemovalPolicy.SNAPSHOT:
// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-attribute-deletionpolicy.html
const snapshottableResourceTypes = [
'AWS::DocDB::DBCluster',
'AWS::EC2::Volume',
'AWS::ElastiCache::CacheCluster',
'AWS::ElastiCache::ReplicationGroup',
'AWS::Neptune::DBCluster',
'AWS::RDS::DBCluster',
'AWS::RDS::DBInstance',
'AWS::Redshift::Cluster',
];
// error if flag is set, warn if flag is not
const problematicSnapshotPolicy = !snapshottableResourceTypes.includes(this.cfnResourceType);
if (problematicSnapshotPolicy) {
if (FeatureFlags.of(this).isEnabled(cxapi.VALIDATE_SNAPSHOT_REMOVAL_POLICY) ) {
throw new Error(`${this.cfnResourceType} does not support snapshot removal policy`);
} else {
Annotations.of(this).addWarningV2(`@aws-cdk/core:${this.cfnResourceType}SnapshotRemovalPolicyIgnored`, `${this.cfnResourceType} does not support snapshot removal policy. This policy will be ignored.`);
}
}
deletionPolicy = CfnDeletionPolicy.SNAPSHOT;
updateReplacePolicy = CfnDeletionPolicy.SNAPSHOT;
break;
default:
throw new Error(`Invalid removal policy: ${policy}`);
}
this.cfnOptions.deletionPolicy = deletionPolicy;
if (options.applyToUpdateReplacePolicy !== false) {
this.cfnOptions.updateReplacePolicy = updateReplacePolicy;
}
}
/**
* Returns a token for an runtime attribute of this resource.
* Ideally, use generated attribute accessors (e.g. `resource.arn`), but this can be used for future compatibility
* in case there is no generated attribute.
* @param attributeName The name of the attribute.
*/
public getAtt(attributeName: string, typeHint?: ResolutionTypeHint): Reference {
return CfnReference.for(this, attributeName, undefined, typeHint);
}
/**
* 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`).
*
* If the override is nested, separate each nested level using a dot (.) in the path parameter.
* If there is an array as part of the nesting, specify the index in the path.
*
* To include a literal `.` in the property name, prefix with a `\`. In most
* programming languages you will need to write this as `"\\."` because the
* `\` itself will need to be escaped.
*
* For example,
* ```typescript
* cfnResource.addOverride('Properties.GlobalSecondaryIndexes.0.Projection.NonKeyAttributes', ['myattribute']);
* cfnResource.addOverride('Properties.GlobalSecondaryIndexes.1.ProjectionType', 'INCLUDE');
* ```
* would add the overrides
* ```json
* "Properties": {
* "GlobalSecondaryIndexes": [
* {
* "Projection": {
* "NonKeyAttributes": [ "myattribute" ]
* ...
* }
* ...
* },
* {
* "ProjectionType": "INCLUDE"
* ...
* },
* ]
* ...
* }
* ```
*
* The `value` argument to `addOverride` will not be processed or translated
* in any way. Pass raw JSON values in here with the correct capitalization
* for CloudFormation. If you pass CDK classes or structs, they will be
* rendered with lowercased key names, and CloudFormation will reject the
* template.
*
* @param path - The path of the property, you can use dot notation to
* override values in complex types. Any intermediate keys
* will be created as needed.
* @param value - The value. Could be primitive or complex.
*/
public addOverride(path: string, value: any) {
const parts = splitOnPeriods(path);
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);
}
/**
* Indicates that this resource depends on another resource and cannot be
* provisioned unless the other resource has been successfully provisioned.
*
* @deprecated use addDependency
*/
public addDependsOn(target: CfnResource) {
return this.addDependency(target);
}
/**
* Indicates that this resource depends on another resource and cannot be
* provisioned unless the other resource has been successfully provisioned.
*
* This can be used for resources across stacks (or nested stack) boundaries
* and the dependency will automatically be transferred to the relevant scope.
*/
public addDependency(target: CfnResource) {
// skip this dependency if the target is not part of the output
if (!target.shouldSynthesize()) {
return;
}
addDependency(this, target, `{${this.node.path}}.addDependency({${target.node.path}})`);
}
/**
* Indicates that this resource no longer depends on another resource.
*
* This can be used for resources across stacks (including nested stacks)
* and the dependency will automatically be removed from the relevant scope.
*/
public removeDependency(target: CfnResource) : void {
// skip this dependency if the target is not part of the output
if (!target.shouldSynthesize()) {
return;
}
removeDependency(this, target);
}
/**
* Retrieves an array of resources this resource depends on.
*
* This assembles dependencies on resources across stacks (including nested stacks)
* automatically.
*/
public obtainDependencies() {
return obtainDependencies(this);
}
/**
* Replaces one dependency with another.
* @param target The dependency to replace
* @param newTarget The new dependency to add
*/
public replaceDependency(target: CfnResource, newTarget: CfnResource) : void {
if (this.obtainDependencies().includes(target)) {
this.removeDependency(target);
this.addDependency(newTarget);
} else {
throw new Error(`"${Node.of(this).path}" does not depend on "${Node.of(target).path}"`);
}
}
/**
* Add a value to the CloudFormation Resource Metadata
* @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/metadata-section-structure.html
*
* Note that this is a different set of metadata from CDK node metadata; this
* metadata ends up in the stack template under the resource, whereas CDK
* node metadata ends up in the Cloud Assembly.
*/
public addMetadata(key: string, value: any) {
if (!this.cfnOptions.metadata) {
this.cfnOptions.metadata = {};
}
this.cfnOptions.metadata[key] = value;
}
/**
* Retrieve a value value from the CloudFormation Resource Metadata
* @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/metadata-section-structure.html
*
* Note that this is a different set of metadata from CDK node metadata; this
* metadata ends up in the stack template under the resource, whereas CDK
* node metadata ends up in the Cloud Assembly.
*/
public getMetadata(key: string): any {
return this.cfnOptions.metadata?.[key];
}
/**
* @returns a string representation of this resource
*/
public toString() {
return `${super.toString()} [${this.cfnResourceType}]`;
}
/**
* Called by the `addDependency` helper function in order to realize a direct
* dependency between two resources that are directly defined in the same
* stacks.
*
* Use `resource.addDependency` to define the dependency between two resources,
* which also takes stack boundaries into account.
*
* @internal
*/
public _addResourceDependency(target: CfnResource) {
this.dependsOn.add(target);
}
/**
* Get a shallow copy of dependencies between this resource and other resources
* in the same stack.
*/
public obtainResourceDependencies() {
return Array.from(this.dependsOn.values());
}
/**
* Remove a dependency between this resource and other resources in the same
* stack.
*
* @internal
*/
public _removeResourceDependency(target: CfnResource) {
this.dependsOn.delete(target);
}
/**
* Emits CloudFormation for this resource.
* @internal
*/
public _toCloudFormation(): object {
if (!this.shouldSynthesize()) {
return { };
}
try {
const ret = {
Resources: {
// Post-Resolve operation since otherwise deepMerge is going to mix values into
// the Token objects returned by ignoreEmpty.
[this.logicalId]: new PostResolveToken({
Type: this.cfnResourceType,
Properties: ignoreEmpty(this.cfnProperties),
DependsOn: ignoreEmpty(renderDependsOn(this.dependsOn)),
CreationPolicy: capitalizePropertyNames(this, renderCreationPolicy(this.cfnOptions.creationPolicy)),
UpdatePolicy: capitalizePropertyNames(this, this.cfnOptions.updatePolicy),
UpdateReplacePolicy: capitalizePropertyNames(this, this.cfnOptions.updateReplacePolicy),
DeletionPolicy: capitalizePropertyNames(this, this.cfnOptions.deletionPolicy),
Version: this.cfnOptions.version,
Description: this.cfnOptions.description,
Metadata: ignoreEmpty(this.cfnOptions.metadata),
Condition: this.cfnOptions.condition && this.cfnOptions.condition.logicalId,
}, (resourceDef, context) => {
const renderedProps = this.renderProperties(resourceDef.Properties || {});
if (renderedProps) {
const hasDefined = Object.values(renderedProps).find(v => v !== undefined);
resourceDef.Properties = hasDefined !== undefined ? renderedProps : undefined;
}
const resolvedRawOverrides = context.resolve(this.rawOverrides, {
// we need to preserve the empty elements here,
// as that's how removing overrides are represented as
removeEmpty: false,
});
return deepMerge(resourceDef, resolvedRawOverrides);
}),
},
};
return ret;
} catch (e: any) {
// Change message
e.message = `While synthesizing ${this.node.path}: ${e.message}`;
// Adjust stack trace (make it look like node built it, too...)
const trace = this.creationStack;
if (trace) {
const creationStack = ['--- resource created at ---', ...trace].join('\n at ');
const problemTrace = e.stack.slice(e.stack.indexOf(e.message) + e.message.length);
e.stack = `${e.message}\n ${creationStack}\n --- problem discovered at ---${problemTrace}`;
}
// Re-throw
throw e;
}
// returns the set of logical ID (tokens) this resource depends on
// sorted by construct paths to ensure test determinism
function renderDependsOn(dependsOn: Set<CfnResource>) {
return Array
.from(dependsOn)
.sort((x, y) => x.node.path.localeCompare(y.node.path))
.map(r => r.logicalId);
}
function renderCreationPolicy(policy: CfnCreationPolicy | undefined): any {
if (!policy) { return undefined; }
const result: any = { ...policy };
if (policy.resourceSignal && policy.resourceSignal.timeout) {
result.resourceSignal = policy.resourceSignal;
}
return result;
}
}
protected get cfnProperties(): { [key: string]: any } {
const props = this._cfnProperties || {};
const tagMgr = TagManager.of(this);
if (tagMgr) {
const tagsProp: { [key: string]: any } = {};
// If this object has a TagManager, then render it out into the correct field. We assume there
// is no shadow tags object, so we don't pass anything to renderTags().
tagsProp[tagMgr.tagPropertyName] = tagMgr.renderTags();
return deepMerge(props, tagsProp);
}
return props;
}
protected renderProperties(props: {[key: string]: any}): { [key: string]: any } {
return props;
}
/**
* Deprecated
* @deprecated use `updatedProperties`
*
* Return properties modified after initiation
*
* Resources that expose mutable properties should override this function to
* collect and return the properties object for this resource.
*/
protected get updatedProperites(): { [key: string]: any } {
return this.updatedProperties;
}
/**
* Return properties modified after initiation
*
* Resources that expose mutable properties should override this function to
* collect and return the properties object for this resource.
*/
protected get updatedProperties(): { [key: string]: any } {
return this._cfnProperties;
}
protected validateProperties(_properties: any) {
// Nothing
}
/**
* Can be overridden by subclasses to determine if this resource will be rendered
* into the cloudformation template.
*
* @returns `true` if the resource should be included or `false` is the resource
* should be omitted.
*/
protected shouldSynthesize() {
return true;
}
}
export enum TagType {
STANDARD = 'StandardTag',
AUTOSCALING_GROUP = 'AutoScalingGroupTag',
MAP = 'StringToStringMap',
KEY_VALUE = 'KeyValue',
NOT_TAGGABLE = 'NotTaggable',
}
export interface ICfnResourceOptions {
/**
* A condition to associate with this resource. This means that only if the condition evaluates to 'true' when the stack
* is deployed, the resource will be included. This is provided to allow CDK projects to produce legacy templates, but normally
* there is no need to use it in CDK projects.
*/
condition?: CfnCondition;
/**
* Associate the CreationPolicy attribute with a resource to prevent its status from reaching create complete until
* AWS CloudFormation receives a specified number of success signals or the timeout period is exceeded. To signal a
* resource, you can use the cfn-signal helper script or SignalResource API. AWS CloudFormation publishes valid signals
* to the stack events so that you track the number of signals sent.
*/
creationPolicy?: CfnCreationPolicy;
/**
* With the DeletionPolicy attribute you can preserve or (in some cases) backup a resource when its stack is deleted.
* You specify a DeletionPolicy attribute for each resource that you want to control. If a resource has no DeletionPolicy
* attribute, AWS CloudFormation deletes the resource by default. Note that this capability also applies to update operations
* that lead to resources being removed.
*/
deletionPolicy?: CfnDeletionPolicy;
/**
* Use the UpdatePolicy attribute to specify how AWS CloudFormation handles updates to the AWS::AutoScaling::AutoScalingGroup
* resource. AWS CloudFormation invokes one of three update policies depending on the type of change you make or whether a
* scheduled action is associated with the Auto Scaling group.
*/
updatePolicy?: CfnUpdatePolicy;
/**
* Use the UpdateReplacePolicy attribute to retain or (in some cases) backup the existing physical instance of a resource
* when it is replaced during a stack update operation.
*/
updateReplacePolicy?: CfnDeletionPolicy;
/**
* The version of this resource.
* Used only for custom CloudFormation resources.
*
* @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cfn-customresource.html
*/
version?: string;
/**
* The description of this resource.
* Used for informational purposes only, is not processed in any way
* (and stays with the CloudFormation template, is not passed to the underlying resource,
* even if it does have a 'description' property).
*/
description?: string;
/**
* Metadata associated with the CloudFormation resource. This is not the same as the construct metadata which can be added
* using construct.addMetadata(), but would not appear in the CloudFormation template automatically.
*/
metadata?: { [key: string]: any };
}
/**
* Object keys that deepMerge should not consider. Currently these include
* CloudFormation intrinsics
*
* @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html
*/
const MERGE_EXCLUDE_KEYS: string[] = [
'Ref',
'Fn::Base64',
'Fn::Cidr',
'Fn::FindInMap',
'Fn::GetAtt',
'Fn::GetAZs',
'Fn::ImportValue',
'Fn::Join',
'Fn::Select',
'Fn::Split',
'Fn::Sub',
'Fn::Transform',
'Fn::And',
'Fn::Equals',
'Fn::If',
'Fn::Not',
'Fn::Or',
];
/**
* Merges `source` into `target`, overriding any existing values.
* `null`s will cause a value to be deleted.
*/
function deepMerge(target: any, ...sources: any[]) {
for (const source of sources) {
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)) {
if (key === '__proto__' || key === 'constructor') {
continue;
}
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] = {};
/**
* If we have something that looks like:
*
* target: { Type: 'MyResourceType', Properties: { prop1: { Ref: 'Param' } } }
* sources: [ { Properties: { prop1: [ 'Fn::Join': ['-', 'hello', 'world'] ] } } ]
*
* Eventually we will get to the point where we have
*
* target: { prop1: { Ref: 'Param' } }
* sources: [ { prop1: { 'Fn::Join': ['-', 'hello', 'world'] } } ]
*
* We need to recurse 1 more time, but if we do we will end up with
* { prop1: { Ref: 'Param', 'Fn::Join': ['-', 'hello', 'world'] } }
* which is not what we want.
*
* Instead we check to see whether the `target` value (i.e. target.prop1)
* is an object that contains a key that we don't want to recurse on. If it does
* then we essentially drop it and end up with:
*
* { prop1: { 'Fn::Join': ['-', 'hello', 'world'] } }
*/
} else if (Object.keys(target[key]).length === 1) {
if (MERGE_EXCLUDE_KEYS.includes(Object.keys(target[key])[0])) {
target[key] = {};
}
}
/**
* There might also be the case where the source is an intrinsic
*
* target: {
* Type: 'MyResourceType',
* Properties: {
* prop1: { subprop: { name: { 'Fn::GetAtt': 'abc' } } }
* }
* }
* sources: [ {
* Properties: {
* prop1: { subprop: { 'Fn::If': ['SomeCondition', {...}, {...}] }}
* }
* } ]
*
* We end up in a place that is the reverse of the above check, the source
* becomes an intrinsic before the target
*
* target: { subprop: { name: { 'Fn::GetAtt': 'abc' } } }
* sources: [{
* 'Fn::If': [ 'MyCondition', {...}, {...} ]
* }]
*/
if (Object.keys(value).length === 1) {
if (MERGE_EXCLUDE_KEYS.includes(Object.keys(value)[0])) {
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;
}
/**
* Split on periods while processing escape characters \
*/
function splitOnPeriods(x: string): string[] {
// Build this list in reverse because it's more convenient to get the "current"
// item by doing ret[0] than by ret[ret.length - 1].
const ret = [''];
for (let i = 0; i < x.length; i++) {
if (x[i] === '\\' && i + 1 < x.length) {
ret[0] += x[i + 1];
i++;
} else if (x[i] === '.') {
ret.unshift('');
} else {
ret[0] += x[i];
}
}
ret.reverse();
return ret;
}