Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): weak references #8891

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/@aws-cdk/core/README.md
Expand Up @@ -94,6 +94,25 @@ nested stack and referenced using `Fn::GetAtt "Outputs.Xxx"` from the parent.

Nested stacks also support the use of Docker image and file assets.

## Weak References

The CDK automatically wires up cross-stack references within a single app. By default, the CDK uses the CloudFormation's
[`Fn::ImportValue`](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-importvalue.html)
instrinsic to achieve this. To prevent interruption, CloudFormation restricts modification of any `Output` of a stack
that is imported via this intrinsic.

In many cases, this can be too restrictive. Some resources guarantee that change to its physical name does not entirely
delete the resource. Weak references relax this restriction for specific Constructs. Instead of using the
`Fn::ImportValue` intrinsic, [SSM Parameter
Types](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/parameters-section-structure.html#aws-ssm-parameter-types)
are used instead.

Enable weak reference for a resource:

```ts
const resource = Cfn...(); // One of the constructs auto-generated from CloudFormation
resource.enableWeakReferences();
```

## Durations

Expand Down
17 changes: 17 additions & 0 deletions packages/@aws-cdk/core/lib/cfn-resource.ts
Expand Up @@ -75,6 +75,8 @@ export class CfnResource extends CfnRefElement {
*/
private readonly dependsOn = new Set<CfnResource>();

private _weakReference: boolean = false;

/**
* Creates a resource construct.
* @param cfnResourceType The CloudFormation type of this resource (e.g. AWS::DynamoDB::Table)
Expand Down Expand Up @@ -321,6 +323,21 @@ export class CfnResource extends CfnRefElement {
}
}

/**
* Calling this method will mark all cross-stack references to this node as weak references.
* See README.md to learn more about weak references.
*/
public enableWeakReference() {
this._weakReference = true;
}

/**
* Whether this node should use weak references when referred across stacks.
*/
public get weakReference() {
return this._weakReference;
}

protected get cfnProperties(): { [key: string]: any } {
const props = this._cfnProperties || {};
if (TagManager.isTaggable(this)) {
Expand Down
37 changes: 36 additions & 1 deletion packages/@aws-cdk/core/lib/private/refs.ts
Expand Up @@ -4,6 +4,7 @@
import { CfnElement } from '../cfn-element';
import { CfnOutput } from '../cfn-output';
import { CfnParameter } from '../cfn-parameter';
import { CfnResource } from '../cfn-resource';
import { Construct, IConstruct } from '../construct-compat';
import { Reference } from '../reference';
import { IResolvable } from '../resolvable';
Expand All @@ -12,6 +13,7 @@ import { Token } from '../token';
import { CfnReference } from './cfn-reference';
import { Intrinsic } from './intrinsic';
import { findTokens } from './resolve';
import { SsmStringParameter } from './ssm-parameter';
import { makeUniqueId } from './uniqueid';

/**
Expand Down Expand Up @@ -99,6 +101,10 @@ function resolveValue(consumer: Stack, reference: CfnReference): IResolvable {
consumer.addDependency(producer,
`${consumer.node.path} -> ${reference.target.node.path}.${reference.displayName}`);

if (CfnResource.isCfnResource(reference.target) && reference.target.weakReference) {
return createSsmParameter(consumer, reference);
}

return createImportValue(reference);
}

Expand Down Expand Up @@ -156,11 +162,40 @@ function findAllReferences(root: IConstruct) {
// export/import
// ------------------------------------------------------------------------------------------------

function createSsmParameter(consumer: Stack, reference: CfnReference): Intrinsic {
const exportingStack = Stack.of(reference.target);
const id = makeUniqueId([ JSON.stringify(exportingStack.resolve(reference)) ]);
nija-at marked this conversation as resolved.
Show resolved Hide resolved

// include the stack name, SSM parameters are not stack-local.
// use SSM hierarchy format - https://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-paramstore-su-organize.html
const parameterName = `/stacks/${exportingStack.stackName}/${id}`;
const constructId = `SSMExport${id}`;

const existing = exportingStack.node.tryFindChild(constructId);
if (!existing) {
new SsmStringParameter(exportingStack, constructId, {
name: parameterName,
value: Token.asString(reference),
description: `[cdk] exported from stack "${exportingStack.stackName}" for use as parameter in a different stack`,
});
}

const parameterId = `SsmParameterValue:${parameterName}`;
let cfnparameter = consumer.node.tryFindChild(parameterId) as CfnParameter | undefined;
if (!cfnparameter) {
cfnparameter = new CfnParameter(consumer, parameterId, {
type: 'AWS::SSM::Parameter::Value<String>',
default: parameterName,
});
}
return new Intrinsic({ Ref: cfnparameter.logicalId });
}

/**
* Imports a value from another stack by creating an "Output" with an "ExportName"
* and returning an "Fn::ImportValue" token.
*/
function createImportValue(reference: Reference): Intrinsic {
function createImportValue(reference: CfnReference): Intrinsic {
const exportingStack = Stack.of(reference.target);

// Ensure a singleton "Exports" scoping Construct
Expand Down
33 changes: 33 additions & 0 deletions packages/@aws-cdk/core/lib/private/ssm-parameter.ts
@@ -0,0 +1,33 @@
import { CfnResource } from '../cfn-resource';
import { Construct } from '../construct-compat';

export interface SsmStringParameterProps {
/**
* Name of the ssm string parameter. Should be unique for the AWS account.
*/
readonly name: string;
/**
* The value stored in SSM
*/
readonly value: string;
/**
* Optional description
*/
readonly description?: string;
}

export class SsmStringParameter extends Construct {
constructor(scope: Construct, id: string, props: SsmStringParameterProps) {
super(scope, id);

new CfnResource(this, id, {
type: 'AWS::SSM::Parameter',
properties: {
Name: props.name,
Description: props.description,
Type: 'String',
Value: props.value,
},
});
}
}
31 changes: 31 additions & 0 deletions packages/@aws-cdk/core/test/private/test.ssm-parameter.ts
@@ -0,0 +1,31 @@
import { Test } from 'nodeunit';
import { Stack } from '../../lib';
import { SsmStringParameter } from '../../lib/private/ssm-parameter';
import { toCloudFormation } from '../util';

export = {
'ssm string parameter construct is correctly rendered'(test: Test) {
const stack = new Stack();
new SsmStringParameter(stack, 'string-param', {
name: 'ParamName',
value: 'ParamValue',
description: 'ParamDescription',
});

test.deepEqual(toCloudFormation(stack), {
Resources: {
stringparam5E4312EC: {
Type: 'AWS::SSM::Parameter',
Properties: {
Name: 'ParamName',
Description: 'ParamDescription',
Type: 'String',
Value: 'ParamValue',
},
},
},
});

test.done();
},
};
154 changes: 152 additions & 2 deletions packages/@aws-cdk/core/test/test.stack.ts
@@ -1,8 +1,8 @@
import * as cxapi from '@aws-cdk/cx-api';
import { Test } from 'nodeunit';
import {
App, CfnCondition, CfnInclude, CfnOutput, CfnParameter,
CfnResource, Construct, ConstructNode, Lazy, ScopedAws, Stack, Tag, validateString } from '../lib';
App, CfnCondition, CfnInclude, CfnOutput,
CfnParameter, CfnResource, Construct, ConstructNode, Lazy, ScopedAws, Stack, Tag, validateString } from '../lib';
import { Intrinsic } from '../lib/private/intrinsic';
import { PostResolveToken } from '../lib/util';
import { toCloudFormation } from './util';
Expand Down Expand Up @@ -866,6 +866,156 @@ export = {

test.done();
},

'weak references across stacks correctly resolve to ssm parameters'(test: Test) {
// GIVEN
const app = new App();
const stack1 = new Stack(app, 'Stack1');
const stack2 = new Stack(app, 'Stack2');
const resource = new CfnResource(stack1, 'Resource1', {
type: 'Foo::Bar',
});
resource.enableWeakReference();

// WHEN
new CfnResource(stack2, 'Resource2', {
type: 'Foo::Baz',
properties: {
Value: resource.ref,
},
});

// THEN
const assembly = app.synth();
const template1 = assembly.getStackByName(stack1.stackName).template;
const template2 = assembly.getStackByName(stack2.stackName).template;

test.deepEqual(template1, {
Resources: {
Resource1: { Type: 'Foo::Bar' },
SSMExportRefResource149D6AE9B: {
Type: 'AWS::SSM::Parameter',
Properties: {
Name: '/stacks/Stack1/RefResource1',
Description: '[cdk] exported from stack "Stack1" for use as parameter in a different stack',
Type: 'String',
Value: { Ref: 'Resource1' },
},
},
},
});
test.deepEqual(template2, {
Resources: {
Resource2: {
Type: 'Foo::Baz',
Properties: {
Value: {
Ref: 'SsmParameterValuestacksStack1RefResource1',
},
},
},
},
Parameters: {
SsmParameterValuestacksStack1RefResource1: {
Type: 'AWS::SSM::Parameter::Value<String>',
Default: '/stacks/Stack1/RefResource1',
},
},
});

test.done();
},

'weak references across stacks with lazy tokens work'(test: Test) {
// GIVEN
const app = new App();
const stack1 = new Stack(app, 'Stack1');
const resource = new CfnResource(stack1, 'Resource1', {
type: 'Foo::Bar',
properties: {
Key1: 'Value1',
},
});
resource.enableWeakReference();
const stack2 = new Stack(app, 'Stack2');

// WHEN - used in another stack
new CfnResource(stack2, 'Resource2', {
type: 'Foo::Bar',
properties: {
Key1: Lazy.stringValue({ produce: () => resource.ref }),
},
});

// THEN
const assembly = app.synth();
const template2 = assembly.getStackByName(stack2.stackName).template;
test.deepEqual(template2, {
Resources: {
Resource2: {
Type: 'Foo::Bar',
Properties: {
Key1: {
Ref: 'SsmParameterValuestacksStack1RefResource1',
},
},
},
},
Parameters: {
SsmParameterValuestacksStack1RefResource1: {
Type: 'AWS::SSM::Parameter::Value<String>',
Default: '/stacks/Stack1/RefResource1',
},
},
});

test.done();
},

'duplicate weak references produce a single SsmStringParameter'(test: Test) {
// GIVEN
const app = new App();
const stack1 = new Stack(app, 'Stack1');
const resource = new CfnResource(stack1, 'Resource1', {
type: 'Foo::Bar',
properties: {
Key1: 'Value1',
},
});
resource.enableWeakReference();
const stack2 = new Stack(app, 'Stack2');

// WHEN - used in another stack
new CfnResource(stack2, 'Resource2', {
type: 'Foo::Bar',
properties: {
Key1: resource.ref,
},
});
new CfnResource(stack2, 'Resource3', {
type: 'Foo::Bar',
properties: {
Key1: resource.ref,
},
});

const assembly = app.synth();
const template1 = assembly.getStackByName(stack1.stackName).template;
const template2 = assembly.getStackByName(stack2.stackName).template;

// THEN
const resources = template1.Resources as { [key: string]: any };
const ssmResources = Object.values(resources).filter((r) => r.Type === 'AWS::SSM::Parameter');
test.deepEqual(ssmResources.length, 1);

const parameters = template2.Parameters as { [key: string]: any };
const ssmParameters = Object.values(parameters).filter((p) => p.Type === 'AWS::SSM::Parameter::Value<String>');
test.deepEqual(ssmParameters.length, 1);

test.deepEqual(ssmResources[0].Properties.Name, ssmParameters[0].Default);

test.done();
},
};

class StackWithPostProcessor extends Stack {
Expand Down