Skip to content

Commit

Permalink
feat(core): stack.exportValue() can be used to solve "deadly embrac…
Browse files Browse the repository at this point in the history
…e" (#12778)

Deadly embrace (<3 who came up with this term) is an issue where a consumer
stack depends on a producer stack via CloudFormation Exports, and you want to
remove the use from the consumer.

Removal of the resource sharing implicitly removes the CloudFormation Export,
but now CloudFormation won't let you deploy that because the deployment order
is always forced to be (1st) producer (2nd) consumer, and when the producer deploys
and tries to remove the Export, the consumer is still using it.

The best way to work around it is to manually ensure the CloudFormation Export
exists while you remove the consuming relationship. @skinny85 has a [blog
post] about this, but the mechanism can be more smooth.

Add a method, `stack.exportValue(...)` which can be used to
create the Export for the duration of the deployment that breaks
the relationship, and add an explanation of how to use it.

Genericize the method a bit so it also solves a long-standing issue about no L2 support for exports.

Fixes #7602, fixes #2036.

[blog post]: https://www.endoflineblog.com/cdk-tips-03-how-to-unblock-cross-stack-references


----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
rix0rrr committed Feb 1, 2021
1 parent 415eb86 commit 3b66088
Show file tree
Hide file tree
Showing 5 changed files with 310 additions and 63 deletions.
59 changes: 59 additions & 0 deletions packages/@aws-cdk/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,65 @@ nested stack and referenced using `Fn::GetAtt "Outputs.Xxx"` from the parent.

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

## Accessing resources in a different stack

You can access resources in a different stack, as long as they are in the
same account and AWS Region. The following example defines the stack `stack1`,
which defines an Amazon S3 bucket. Then it defines a second stack, `stack2`,
which takes the bucket from stack1 as a constructor property.

```ts
const prod = { account: '123456789012', region: 'us-east-1' };

const stack1 = new StackThatProvidesABucket(app, 'Stack1' , { env: prod });

// stack2 will take a property { bucket: IBucket }
const stack2 = new StackThatExpectsABucket(app, 'Stack2', {
bucket: stack1.bucket,
env: prod
});
```

If the AWS CDK determines that the resource is in the same account and
Region, but in a different stack, it automatically synthesizes AWS
CloudFormation
[Exports](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-stack-exports.html)
in the producing stack and an
[Fn::ImportValue](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-importvalue.html)
in the consuming stack to transfer that information from one stack to the
other.

### Removing automatic cross-stack references

The automatic references created by CDK when you use resources across stacks
are convenient, but may block your deployments if you want to remove the
resources that are referenced in this way. You will see an error like:

```text
Export Stack1:ExportsOutputFnGetAtt-****** cannot be deleted as it is in use by Stack1
```

Let's say there is a Bucket in the `stack1`, and the `stack2` references its
`bucket.bucketName`. You now want to remove the bucket and run into the error above.

It's not safe to remove `stack1.bucket` while `stack2` is still using it, so
unblocking yourself from this is a two-step process. This is how it works:

DEPLOYMENT 1: break the relationship

- Make sure `stack2` no longer references `bucket.bucketName` (maybe the consumer
stack now uses its own bucket, or it writes to an AWS DynamoDB table, or maybe you just
remove the Lambda Function altogether).
- In the `stack1` class, call `this.exportAttribute(this.bucket.bucketName)`. This
will make sure the CloudFormation Export continues to exist while the relationship
between the two stacks is being broken.
- Deploy (this will effectively only change the `stack2`, but it's safe to deploy both).

DEPLOYMENT 2: remove the resource

- You are now free to remove the `bucket` resource from `stack1`.
- Don't forget to remove the `exportAttribute()` call as well.
- Deploy again (this time only the `stack1` will be changed -- the bucket will be deleted).

## Durations

Expand Down
79 changes: 25 additions & 54 deletions packages/@aws-cdk/core/lib/private/refs.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
// ----------------------------------------------------
// CROSS REFERENCES
// ----------------------------------------------------
import * as cxapi from '@aws-cdk/cx-api';

import { CfnElement } from '../cfn-element';
import { CfnOutput } from '../cfn-output';
import { CfnParameter } from '../cfn-parameter';
import { Construct, IConstruct } from '../construct-compat';
import { FeatureFlags } from '../feature-flags';
import { IConstruct } from '../construct-compat';
import { Names } from '../names';
import { Reference } from '../reference';
import { IResolvable } from '../resolvable';
import { Stack } from '../stack';
import { Token } from '../token';
import { Token, Tokenization } from '../token';
import { CfnReference } from './cfn-reference';
import { Intrinsic } from './intrinsic';
import { findTokens } from './resolve';
import { makeUniqueId } from './uniqueid';

/**
* This is called from the App level to resolve all references defined. Each
Expand Down Expand Up @@ -167,55 +164,10 @@ function findAllReferences(root: IConstruct) {
function createImportValue(reference: Reference): Intrinsic {
const exportingStack = Stack.of(reference.target);

// Ensure a singleton "Exports" scoping Construct
// This mostly exists to trigger LogicalID munging, which would be
// disabled if we parented constructs directly under Stack.
// Also it nicely prevents likely construct name clashes
const exportsScope = getCreateExportsScope(exportingStack);
const importExpr = exportingStack.exportValue(reference);

// Ensure a singleton CfnOutput for this value
const resolved = exportingStack.resolve(reference);
const id = 'Output' + JSON.stringify(resolved);
const exportName = generateExportName(exportsScope, id);

if (Token.isUnresolved(exportName)) {
throw new Error(`unresolved token in generated export name: ${JSON.stringify(exportingStack.resolve(exportName))}`);
}

const output = exportsScope.node.tryFindChild(id) as CfnOutput;
if (!output) {
new CfnOutput(exportsScope, id, { value: Token.asString(reference), exportName });
}

// We want to return an actual FnImportValue Token here, but Fn.importValue() returns a 'string',
// so construct one in-place.
return new Intrinsic({ 'Fn::ImportValue': exportName });
}

function getCreateExportsScope(stack: Stack) {
const exportsName = 'Exports';
let stackExports = stack.node.tryFindChild(exportsName) as Construct;
if (stackExports === undefined) {
stackExports = new Construct(stack, exportsName);
}

return stackExports;
}

function generateExportName(stackExports: Construct, id: string) {
const stackRelativeExports = FeatureFlags.of(stackExports).isEnabled(cxapi.STACK_RELATIVE_EXPORTS_CONTEXT);
const stack = Stack.of(stackExports);

const components = [
...stackExports.node.scopes
.slice(stackRelativeExports ? stack.node.scopes.length : 2)
.map(c => c.node.id),
id,
];
const prefix = stack.stackName ? stack.stackName + ':' : '';
const localPart = makeUniqueId(components);
const maxLength = 255;
return prefix + localPart.slice(Math.max(0, localPart.length - maxLength + prefix.length));
// I happen to know this returns a Fn.importValue() which implements Intrinsic.
return Tokenization.reverseCompleteString(importExpr) as Intrinsic;
}

// ------------------------------------------------------------------------------------------------
Expand Down Expand Up @@ -262,6 +214,25 @@ function createNestedStackOutput(producer: Stack, reference: Reference): CfnRefe
return producer.nestedStackResource.getAtt(`Outputs.${output.logicalId}`) as CfnReference;
}

/**
* Translate a Reference into a nested stack into a value in the parent stack
*
* Will create Outputs along the chain of Nested Stacks, and return the final `{ Fn::GetAtt }`.
*/
export function referenceNestedStackValueInParent(reference: Reference, targetStack: Stack) {
let currentStack = Stack.of(reference.target);
if (currentStack !== targetStack && !isNested(currentStack, targetStack)) {
throw new Error(`Referenced resource must be in stack '${targetStack.node.path}', got '${reference.target.node.path}'`);
}

while (currentStack !== targetStack) {
reference = createNestedStackOutput(Stack.of(reference.target), reference);
currentStack = Stack.of(reference.target);
}

return reference;
}

/**
* @returns true if this stack is a direct or indirect parent of the nested
* stack `nested`.
Expand All @@ -282,4 +253,4 @@ function isNested(nested: Stack, parent: Stack): boolean {

// recurse with the child's direct parent
return isNested(nested.nestedStackParent, parent);
}
}
143 changes: 135 additions & 8 deletions packages/@aws-cdk/core/lib/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,93 @@ export class Stack extends CoreConstruct implements ITaggable {
}
}

/**
* Create a CloudFormation Export for a value
*
* Returns a string representing the corresponding `Fn.importValue()`
* expression for this Export. You can control the name for the export by
* passing the `name` option.
*
* If you don't supply a value for `name`, the value you're exporting must be
* a Resource attribute (for example: `bucket.bucketName`) and it will be
* given the same name as the automatic cross-stack reference that would be created
* if you used the attribute in another Stack.
*
* One of the uses for this method is to *remove* the relationship between
* two Stacks established by automatic cross-stack references. It will
* temporarily ensure that the CloudFormation Export still exists while you
* remove the reference from the consuming stack. After that, you can remove
* the resource and the manual export.
*
* ## Example
*
* Here is how the process works. Let's say there are two stacks,
* `producerStack` and `consumerStack`, and `producerStack` has a bucket
* called `bucket`, which is referenced by `consumerStack` (perhaps because
* an AWS Lambda Function writes into it, or something like that).
*
* It is not safe to remove `producerStack.bucket` because as the bucket is being
* deleted, `consumerStack` might still be using it.
*
* Instead, the process takes two deployments:
*
* ### Deployment 1: break the relationship
*
* - Make sure `consumerStack` no longer references `bucket.bucketName` (maybe the consumer
* stack now uses its own bucket, or it writes to an AWS DynamoDB table, or maybe you just
* remove the Lambda Function altogether).
* - In the `ProducerStack` class, call `this.exportValue(this.bucket.bucketName)`. This
* will make sure the CloudFormation Export continues to exist while the relationship
* between the two stacks is being broken.
* - Deploy (this will effectively only change the `consumerStack`, but it's safe to deploy both).
*
* ### Deployment 2: remove the bucket resource
*
* - You are now free to remove the `bucket` resource from `producerStack`.
* - Don't forget to remove the `exportValue()` call as well.
* - Deploy again (this time only the `producerStack` will be changed -- the bucket will be deleted).
*/
public exportValue(exportedValue: any, options: ExportValueOptions = {}) {
if (options.name) {
new CfnOutput(this, `Export${options.name}`, {
value: exportedValue,
exportName: options.name,
});
return Fn.importValue(options.name);
}

const resolvable = Tokenization.reverse(exportedValue);
if (!resolvable || !Reference.isReference(resolvable)) {
throw new Error('exportValue: either supply \'name\' or make sure to export a resource attribute (like \'bucket.bucketName\')');
}

// "teleport" the value here, in case it comes from a nested stack. This will also
// ensure the value is from our own scope.
const exportable = referenceNestedStackValueInParent(resolvable, this);

// Ensure a singleton "Exports" scoping Construct
// This mostly exists to trigger LogicalID munging, which would be
// disabled if we parented constructs directly under Stack.
// Also it nicely prevents likely construct name clashes
const exportsScope = getCreateExportsScope(this);

// Ensure a singleton CfnOutput for this value
const resolved = this.resolve(exportable);
const id = 'Output' + JSON.stringify(resolved);
const exportName = generateExportName(exportsScope, id);

if (Token.isUnresolved(exportName)) {
throw new Error(`unresolved token in generated export name: ${JSON.stringify(this.resolve(exportName))}`);
}

const output = exportsScope.node.tryFindChild(id) as CfnOutput;
if (!output) {
new CfnOutput(exportsScope, id, { value: Token.asString(exportable), exportName });
}

return Fn.importValue(exportName);
}

/**
* Returns the naming scheme used to allocate logical IDs. By default, uses
* the `HashedAddressingScheme` but this method can be overridden to customize
Expand Down Expand Up @@ -1143,18 +1230,58 @@ function makeStackName(components: string[]) {
return makeUniqueId(components);
}

function getCreateExportsScope(stack: Stack) {
const exportsName = 'Exports';
let stackExports = stack.node.tryFindChild(exportsName) as CoreConstruct;
if (stackExports === undefined) {
stackExports = new CoreConstruct(stack, exportsName);
}

return stackExports;
}

function generateExportName(stackExports: CoreConstruct, id: string) {
const stackRelativeExports = FeatureFlags.of(stackExports).isEnabled(cxapi.STACK_RELATIVE_EXPORTS_CONTEXT);
const stack = Stack.of(stackExports);

const components = [
...stackExports.node.scopes
.slice(stackRelativeExports ? stack.node.scopes.length : 2)
.map(c => c.node.id),
id,
];
const prefix = stack.stackName ? stack.stackName + ':' : '';
const localPart = makeUniqueId(components);
const maxLength = 255;
return prefix + localPart.slice(Math.max(0, localPart.length - maxLength + prefix.length));
}

interface StackDependency {
stack: Stack;
reasons: string[];
}

/**
* Options for the `stack.exportValue()` method
*/
export interface ExportValueOptions {
/**
* The name of the export to create
*
* @default - A name is automatically chosen
*/
readonly name?: string;
}

// These imports have to be at the end to prevent circular imports
import { CfnOutput } from './cfn-output';
import { addDependency } from './deps';
import { FileSystem } from './fs';
import { Names } from './names';
import { Reference } from './reference';
import { IResolvable } from './resolvable';
import { DefaultStackSynthesizer, IStackSynthesizer, LegacyStackSynthesizer } from './stack-synthesizers';
import { Stage } from './stage';
import { ITaggable, TagManager } from './tag-manager';
import { Token } from './token';
import { FileSystem } from './fs';
import { Names } from './names';

interface StackDependency {
stack: Stack;
reasons: string[];
}
import { Token, Tokenization } from './token';
import { referenceNestedStackValueInParent } from './private/refs';
26 changes: 26 additions & 0 deletions packages/@aws-cdk/core/lib/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,19 @@ export class Tokenization {
return TokenMap.instance().splitString(s);
}

/**
* Un-encode a string which is either a complete encoded token, or doesn't contain tokens at all
*
* It's illegal for the string to be a concatenation of an encoded token and something else.
*/
public static reverseCompleteString(s: string): IResolvable | undefined {
const fragments = Tokenization.reverseString(s);
if (fragments.length !== 1) {
throw new Error(`Tokenzation.reverseCompleteString: argument must not be a concatentation, got '${s}'`);
}
return fragments.firstToken;
}

/**
* Un-encode a Tokenized value from a number
*/
Expand All @@ -146,6 +159,19 @@ export class Tokenization {
return TokenMap.instance().lookupList(l);
}

/**
* Reverse any value into a Resolvable, if possible
*
* In case of a string, the string must not be a concatenation.
*/
public static reverse(x: any): IResolvable | undefined {
if (Tokenization.isResolvable(x)) { return x; }
if (typeof x === 'string') { return Tokenization.reverseCompleteString(x); }
if (Array.isArray(x)) { return Tokenization.reverseList(x); }
if (typeof x === 'number') { return Tokenization.reverseNumber(x); }
return undefined;
}

/**
* Resolves an object by evaluating all tokens and removing any undefined or empty objects or arrays.
* Values can only be primitives, arrays or tokens. Other objects (i.e. with methods) will be rejected.
Expand Down
Loading

0 comments on commit 3b66088

Please sign in to comment.