diff --git a/packages/@aws-cdk/core/README.md b/packages/@aws-cdk/core/README.md index 8c5ce270d8dc1..880aea79a55fc 100644 --- a/packages/@aws-cdk/core/README.md +++ b/packages/@aws-cdk/core/README.md @@ -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 diff --git a/packages/@aws-cdk/core/lib/private/refs.ts b/packages/@aws-cdk/core/lib/private/refs.ts index 46d44563b4a96..27618d6776f21 100644 --- a/packages/@aws-cdk/core/lib/private/refs.ts +++ b/packages/@aws-cdk/core/lib/private/refs.ts @@ -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 @@ -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; } // ------------------------------------------------------------------------------------------------ @@ -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`. @@ -282,4 +253,4 @@ function isNested(nested: Stack, parent: Stack): boolean { // recurse with the child's direct parent return isNested(nested.nestedStackParent, parent); -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/core/lib/stack.ts b/packages/@aws-cdk/core/lib/stack.ts index c6a8a56916f2f..53e7adfdc206e 100644 --- a/packages/@aws-cdk/core/lib/stack.ts +++ b/packages/@aws-cdk/core/lib/stack.ts @@ -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 @@ -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'; diff --git a/packages/@aws-cdk/core/lib/token.ts b/packages/@aws-cdk/core/lib/token.ts index 5f98db7a4f11f..4bbcdb454f9bd 100644 --- a/packages/@aws-cdk/core/lib/token.ts +++ b/packages/@aws-cdk/core/lib/token.ts @@ -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 */ @@ -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. diff --git a/packages/@aws-cdk/core/test/stack.test.ts b/packages/@aws-cdk/core/test/stack.test.ts index ceaf6a7c96e99..8891dbaa138c6 100644 --- a/packages/@aws-cdk/core/test/stack.test.ts +++ b/packages/@aws-cdk/core/test/stack.test.ts @@ -2,7 +2,9 @@ import * as cxapi from '@aws-cdk/cx-api'; import { testFutureBehavior, testLegacyBehavior } from 'cdk-build-tools/lib/feature-flag'; import { App, CfnCondition, CfnInclude, CfnOutput, CfnParameter, - CfnResource, Construct, Lazy, ScopedAws, Stack, validateString, ISynthesisSession, Tags, LegacyStackSynthesizer, DefaultStackSynthesizer, + CfnResource, Construct, Lazy, ScopedAws, Stack, validateString, + ISynthesisSession, Tags, LegacyStackSynthesizer, DefaultStackSynthesizer, + NestedStack, } from '../lib'; import { Intrinsic } from '../lib/private/intrinsic'; import { resolveReferences } from '../lib/private/refs'; @@ -535,7 +537,69 @@ describe('stack', () => { expect(assembly.getStackArtifact(child1.artifactId).dependencies.map((x: { id: any; }) => x.id)).toEqual([]); expect(assembly.getStackArtifact(child2.artifactId).dependencies.map((x: { id: any; }) => x.id)).toEqual(['ParentChild18FAEF419']); + }); + + test('automatic cross-stack references and manual exports look the same', () => { + // GIVEN: automatic + const appA = new App(); + const producerA = new Stack(appA, 'Producer'); + const consumerA = new Stack(appA, 'Consumer'); + const resourceA = new CfnResource(producerA, 'Resource', { type: 'AWS::Resource' }); + new CfnOutput(consumerA, 'SomeOutput', { value: `${resourceA.getAtt('Att')}` }); + + // GIVEN: manual + const appM = new App(); + const producerM = new Stack(appM, 'Producer'); + const resourceM = new CfnResource(producerM, 'Resource', { type: 'AWS::Resource' }); + producerM.exportValue(resourceM.getAtt('Att')); + + // THEN - producers are the same + const templateA = appA.synth().getStackByName(producerA.stackName).template; + const templateM = appM.synth().getStackByName(producerM.stackName).template; + + expect(templateA).toEqual(templateM); + }); + + test('automatic cross-stack references and manual exports look the same: nested stack edition', () => { + // GIVEN: automatic + const appA = new App(); + const producerA = new Stack(appA, 'Producer'); + const nestedA = new NestedStack(producerA, 'Nestor'); + const resourceA = new CfnResource(nestedA, 'Resource', { type: 'AWS::Resource' }); + + const consumerA = new Stack(appA, 'Consumer'); + new CfnOutput(consumerA, 'SomeOutput', { value: `${resourceA.getAtt('Att')}` }); + + // GIVEN: manual + const appM = new App(); + const producerM = new Stack(appM, 'Producer'); + const nestedM = new NestedStack(producerM, 'Nestor'); + const resourceM = new CfnResource(nestedM, 'Resource', { type: 'AWS::Resource' }); + producerM.exportValue(resourceM.getAtt('Att')); + + // THEN - producers are the same + const templateA = appA.synth().getStackByName(producerA.stackName).template; + const templateM = appM.synth().getStackByName(producerM.stackName).template; + + expect(templateA).toEqual(templateM); + }); + + test('manual exports require a name if not supplying a resource attribute', () => { + const app = new App(); + const stack = new Stack(app, 'Stack'); + + expect(() => { + stack.exportValue('someValue'); + }).toThrow(/or make sure to export a resource attribute/); + }); + + test('manual exports can also just be used to create an export of anything', () => { + const app = new App(); + const stack = new Stack(app, 'Stack'); + + const importV = stack.exportValue('someValue', { name: 'MyExport' }); + expect(stack.resolve(importV)).toEqual({ 'Fn::ImportValue': 'MyExport' }); }); test('CfnSynthesisError is ignored when preparing cross references', () => {