Skip to content

Commit 46043e0

Browse files
authored
fix(core): exportValue() does not work with resource names (#13052)
Most L2 resources employ the "PhysicalName" protocol, which checks usage of resource names across environment borders, and can automatically turn auto-named resources into physically-named resources if the situation calls for it. Unfortunately, this wrapped token is a generic IResolvable, not a Reference, and so did not work with the `exportValue()` automatic reference detection. Make the token returned by `getResourceNameAttribute()` etc. a `Reference` that mimics the underlying `Reference` to make this work out. Fixes #13002, fixes #12918. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent d043084 commit 46043e0

File tree

5 files changed

+123
-14
lines changed

5 files changed

+123
-14
lines changed

packages/@aws-cdk/aws-s3/test/bucket.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2405,7 +2405,5 @@ describe('bucket', () => {
24052405
expect(() => new s3.Bucket(stack, 'MyBucket', {
24062406
autoDeleteObjects: true,
24072407
})).toThrow(/Cannot use \'autoDeleteObjects\' property on a bucket without setting removal policy to \'DESTROY\'/);
2408-
2409-
24102408
});
24112409
});

packages/@aws-cdk/core/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,15 +141,15 @@ DEPLOYMENT 1: break the relationship
141141
- Make sure `stack2` no longer references `bucket.bucketName` (maybe the consumer
142142
stack now uses its own bucket, or it writes to an AWS DynamoDB table, or maybe you just
143143
remove the Lambda Function altogether).
144-
- In the `stack1` class, call `this.exportAttribute(this.bucket.bucketName)`. This
144+
- In the `stack1` class, call `this.exportValue(this.bucket.bucketName)`. This
145145
will make sure the CloudFormation Export continues to exist while the relationship
146146
between the two stacks is being broken.
147147
- Deploy (this will effectively only change the `stack2`, but it's safe to deploy both).
148148

149149
DEPLOYMENT 2: remove the resource
150150

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

155155
## Durations

packages/@aws-cdk/core/lib/resource.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import { IConstruct, Construct as CoreConstruct } from './construct-compat';
44

55
import { Construct } from 'constructs';
66
import { ArnComponents } from './arn';
7-
import { Lazy } from './lazy';
7+
import { IStringProducer, Lazy } from './lazy';
88
import { generatePhysicalName, isGeneratedWhenNeededMarker } from './private/physical-name-generator';
99
import { IResolveContext } from './resolvable';
1010
import { Stack } from './stack';
11-
import { Token } from './token';
11+
import { Token, Tokenization } from './token';
12+
import { Reference } from './reference';
1213

1314
/**
1415
* Represents the environment a given resource lives in.
@@ -181,7 +182,7 @@ export abstract class Resource extends CoreConstruct implements IResource {
181182
* @experimental
182183
*/
183184
protected getResourceNameAttribute(nameAttr: string) {
184-
return Lazy.uncachedString({
185+
return mimicReference(nameAttr, {
185186
produce: (context: IResolveContext) => {
186187
const consumingStack = Stack.of(context.scope);
187188

@@ -214,8 +215,8 @@ export abstract class Resource extends CoreConstruct implements IResource {
214215
* @experimental
215216
*/
216217
protected getResourceArnAttribute(arnAttr: string, arnComponents: ArnComponents) {
217-
return Token.asString({
218-
resolve: (context: IResolveContext) => {
218+
return mimicReference(arnAttr, {
219+
produce: (context: IResolveContext) => {
219220
const consumingStack = Stack.of(context.scope);
220221
if (this.stack.environment !== consumingStack.environment) {
221222
this._enableCrossEnvironment();
@@ -227,3 +228,28 @@ export abstract class Resource extends CoreConstruct implements IResource {
227228
});
228229
}
229230
}
231+
232+
/**
233+
* Produce a Lazy that is also a Reference (if the base value is a Reference).
234+
*
235+
* If the given value is a Reference (or resolves to a Reference), return a new
236+
* Reference that mimics the same target and display name, but resolves using
237+
* the logic of the passed lazy.
238+
*
239+
* If the given value is NOT a Reference, just return a simple Lazy.
240+
*/
241+
function mimicReference(refSource: any, producer: IStringProducer): string {
242+
const reference = Tokenization.reverse(refSource, {
243+
// If this is an ARN concatenation, just fail to extract a reference.
244+
failConcat: false,
245+
});
246+
if (!Reference.isReference(reference)) {
247+
return Lazy.uncachedString(producer);
248+
}
249+
250+
return Token.asString(new class extends Reference {
251+
resolve(context: IResolveContext) {
252+
return producer.produce(context);
253+
}
254+
}(reference, reference.target, reference.displayName));
255+
}

packages/@aws-cdk/core/lib/token.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,16 @@ export class Tokenization {
164164
*
165165
* In case of a string, the string must not be a concatenation.
166166
*/
167-
public static reverse(x: any): IResolvable | undefined {
167+
public static reverse(x: any, options: ReverseOptions = {}): IResolvable | undefined {
168168
if (Tokenization.isResolvable(x)) { return x; }
169-
if (typeof x === 'string') { return Tokenization.reverseCompleteString(x); }
169+
if (typeof x === 'string') {
170+
if (options.failConcat === false) {
171+
// Handle this specially because reverseCompleteString might fail
172+
const fragments = Tokenization.reverseString(x);
173+
return fragments.length === 1 ? fragments.firstToken : undefined;
174+
}
175+
return Tokenization.reverseCompleteString(x);
176+
}
170177
if (Array.isArray(x)) { return Tokenization.reverseList(x); }
171178
if (typeof x === 'number') { return Tokenization.reverseNumber(x); }
172179
return undefined;
@@ -220,6 +227,20 @@ export class Tokenization {
220227
}
221228
}
222229

230+
/**
231+
* Options for the 'reverse()' operation
232+
*/
233+
export interface ReverseOptions {
234+
/**
235+
* Fail if the given string is a concatenation
236+
*
237+
* If `false`, just return `undefined`.
238+
*
239+
* @default true
240+
*/
241+
readonly failConcat?: boolean;
242+
}
243+
223244
/**
224245
* Options to the resolve() operation
225246
*

packages/@aws-cdk/core/test/cross-environment-token.test.ts

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,27 +244,91 @@ nodeunitShim({
244244
},
245245
});
246246

247+
test.each([undefined, 'SomeName'])('stack.exportValue() on name attributes with PhysicalName=%s', physicalName => {
248+
// Check that automatic exports and manual exports look the same
249+
// GIVEN - auto
250+
const appA = new App();
251+
const producerA = new Stack(appA, 'Producer');
252+
const resourceA = new MyResource(producerA, 'Resource', physicalName);
253+
254+
const consumerA = new Stack(appA, 'Consumer');
255+
new CfnOutput(consumerA, 'ConsumeName', { value: resourceA.name });
256+
new CfnOutput(consumerA, 'ConsumeArn', { value: resourceA.arn });
257+
258+
// WHEN - manual
259+
const appM = new App();
260+
const producerM = new Stack(appM, 'Producer');
261+
const resourceM = new MyResource(producerM, 'Resource', physicalName);
262+
producerM.exportValue(resourceM.name);
263+
producerM.exportValue(resourceM.arn);
264+
265+
// THEN - producers are the same
266+
const templateA = appA.synth().getStackByName(producerA.stackName).template;
267+
const templateM = appM.synth().getStackByName(producerM.stackName).template;
268+
269+
expect(templateA).toEqual(templateM);
270+
});
271+
272+
test('can instantiate resource with constructed physicalname ARN', () => {
273+
const stack = new Stack();
274+
new MyResourceWithConstructedArnAttribute(stack, 'Resource');
275+
});
276+
247277
class MyResource extends Resource {
248278
public readonly arn: string;
249279
public readonly name: string;
250280

251281
constructor(scope: Construct, id: string, physicalName?: string) {
252282
super(scope, id, { physicalName });
253283

254-
this.arn = this.getResourceArnAttribute('simple-arn', {
284+
const res = new CfnResource(this, 'Resource', {
285+
type: 'My::Resource',
286+
properties: {
287+
resourceName: this.physicalName,
288+
},
289+
});
290+
291+
this.name = this.getResourceNameAttribute(res.ref.toString());
292+
this.arn = this.getResourceArnAttribute(res.getAtt('Arn').toString(), {
255293
region: '',
256294
account: '',
257295
resource: 'my-resource',
258296
resourceName: this.physicalName,
259297
service: 'myservice',
260298
});
261-
this.name = this.getResourceNameAttribute('simple-name');
299+
}
300+
}
262301

263-
new CfnResource(this, 'Resource', {
302+
/**
303+
* Some resources build their own Arn attribute by constructing strings
304+
*
305+
* This will be because the L1 doesn't expose a `{ Fn::GetAtt: ['Arn'] }`.
306+
*
307+
* They won't be able to `exportValue()` it, but it shouldn't crash.
308+
*/
309+
class MyResourceWithConstructedArnAttribute extends Resource {
310+
public readonly arn: string;
311+
public readonly name: string;
312+
313+
constructor(scope: Construct, id: string, physicalName?: string) {
314+
super(scope, id, { physicalName });
315+
316+
const res = new CfnResource(this, 'Resource', {
264317
type: 'My::Resource',
265318
properties: {
266319
resourceName: this.physicalName,
267320
},
268321
});
322+
323+
this.name = this.getResourceNameAttribute(res.ref.toString());
324+
this.arn = this.getResourceArnAttribute(Stack.of(this).formatArn({
325+
resource: 'my-resource',
326+
resourceName: res.ref.toString(),
327+
service: 'myservice',
328+
}), {
329+
resource: 'my-resource',
330+
resourceName: this.physicalName,
331+
service: 'myservice',
332+
});
269333
}
270334
}

0 commit comments

Comments
 (0)