Skip to content

Commit

Permalink
WIP allow references to and from (cross-region) nested stack resources
Browse files Browse the repository at this point in the history
Refs: aws#26814
  • Loading branch information
rv2673 committed Sep 10, 2023
1 parent dd93b9e commit 45bc470
Show file tree
Hide file tree
Showing 5 changed files with 1,237 additions and 19 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import * as path from 'path';
import { Construct } from 'constructs';
import { CfnResource, TagType } from '../../cfn-resource';
import { CfnStackProps } from '../../cloudformation.generated';
import { CustomResource } from '../../custom-resource';
import { Lazy } from '../../lazy';
import { RemovalPolicy } from '../../removal-policy';
import { IResolvable } from '../../resolvable';
import { Stack } from '../../stack';
import { ITaggable, TagManager } from '../../tag-manager';
import { IInspectable, TreeInspector } from '../../tree';
import { CustomResourceProvider, CustomResourceProviderRuntime } from '../custom-resource-provider';

const CROSS_REGION_NESTED_STACK_RESOURCE_TYPE = 'Custom::AWSCDKCrossRegionNestedStack';

/**
* Properties for an ExportReader
*/
export interface CrossRegionNestedStackProps extends CfnStackProps {
targetRegion: string
}

/**
* Creates a custom resource that will return a list of stack imports from a given
* The export can then be referenced by the export name.
*
* @internal - this is intentionally not exported from core
*/
export class CrossRegionNestedStack extends CustomResource implements ITaggable, IInspectable {
private properties: {
TemplateURL: string;
TargetRegion: string;
Parameters: IResolvable | Record<string, string> | undefined;
Tags: string[];
TimeoutInMinutes: number | undefined;
NotificationARNs: string[] | undefined;
};

public readonly attrId: string;
public notificationArns?: string[] | undefined;
public parameters?: IResolvable | Record<string, string> | undefined;
public readonly tags: TagManager;
public readonly templateUrl: string;
public timeoutInMinutes?: number | undefined;
public cfnResource: CfnResource;

constructor(scope: Construct, id: string, props: CrossRegionNestedStackProps) {
const tags = new TagManager(TagType.STANDARD, CROSS_REGION_NESTED_STACK_RESOURCE_TYPE, props.tags);
const properties = {
NotificationARNs: props.notificationArns,
Parameters: props.parameters,
Tags: Lazy.list({ produce: () => this.tags.renderTags() }),
TemplateURL: props.templateUrl,
TimeoutInMinutes: props.timeoutInMinutes,
TargetRegion: props.targetRegion,
};
// Should we pass top level scope to always create this in top level stack?
const serviceToken = CustomResourceProvider.getOrCreate(scope, CROSS_REGION_NESTED_STACK_RESOURCE_TYPE, {
codeDirectory: path.join(__dirname, 'handler'),
runtime: CustomResourceProviderRuntime.NODEJS_18_X,
policyStatements: [{
Effect: 'Allow',
Resource: '*',
Action: [
'cloudformation:ListResources',
'cloudformation:DeleteResource',
'cloudformation:CancelResourceRequest',
'cloudformation:GetResource',
'cloudformation:UpdateResource',
'cloudformation:GetResourceRequestStatus',
'cloudformation:ListResourceRequests',
'cloudformation:CreateResource',
],
},
{
Effect: 'Allow',
Resource: '*',
Action: [
'cloudformation:DescribeStacks',
'cloudformation:GetStackPolicy',
'cloudformation:ListStacks',
],
},
{
Effect: 'Allow',
Resource: Stack.of(scope).formatArn({
service: 'iam',
resource: 'role',
resourceName: '*',
}),
Action: [
'iam:PassRole',
],
}],
});

super(scope, id, { serviceToken: serviceToken, properties, resourceType: CROSS_REGION_NESTED_STACK_RESOURCE_TYPE });

this.tags = tags;
this.properties = properties;
this.attrId = this.ref;
this.notificationArns = props.notificationArns;
this.parameters = props.parameters;
this.templateUrl = props.templateUrl;
this.timeoutInMinutes = props.timeoutInMinutes;
this.cfnResource = this.node.defaultChild as CfnResource;
}

public inspect(inspector: TreeInspector): void {
inspector.addAttribute('aws:cdk:cloudformation:type', CROSS_REGION_NESTED_STACK_RESOURCE_TYPE);
inspector.addAttribute('aws:cdk:cloudformation:props', this.properties);
}
}
53 changes: 46 additions & 7 deletions packages/aws-cdk-lib/core/lib/nested-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ import { FileAssetPackaging } from './assets';
import { Fn } from './cfn-fn';
import { Aws } from './cfn-pseudo';
import { CfnResource } from './cfn-resource';
import { CfnStack } from './cloudformation.generated';
import { CfnStack, CfnStackProps } from './cloudformation.generated';
import { CrossRegionNestedStack } from './custom-resource-provider/cross-region-nested-stack-provider/cross-region-nested-stack-provider';
import { Duration } from './duration';
import { Environment } from './environment';
import { Lazy } from './lazy';
import { Names } from './names';
import { RemovalPolicy } from './removal-policy';
import { IResolveContext } from './resolvable';
import { Stack } from './stack';
import { NestedStackSynthesizer } from './stack-synthesizers';
import { ITaggable, TagManager } from './tag-manager';
import { Token } from './token';
import * as cxapi from '../../cx-api';

Expand Down Expand Up @@ -75,6 +78,17 @@ export interface NestedStackProps {
* @default - No description.
*/
readonly description?: string;

/**
* The AWS environment (account/region) where this stack will be deployed.
*
* @default - The environment of the containing `Stack`
*/
readonly env?: Environment;
}

interface StackResourceProps extends CfnStackProps {
targetRegion?: string
}

/**
Expand Down Expand Up @@ -107,17 +121,18 @@ export class NestedStack extends Stack {
public readonly nestedStackResource?: CfnResource;

private readonly parameters: { [name: string]: string };
private readonly resource: CfnStack;
private readonly resource: CfnResource;
private readonly _contextualStackId: string;
private readonly _contextualStackName: string;
private _templateUrl?: string;
private _parentStack: Stack;
private _nestedStackResourceTagManager?: TagManager

constructor(scope: Construct, id: string, props: NestedStackProps = { }) {
const parentStack = findParentStack(scope);

super(scope, id, {
env: { account: parentStack.account, region: parentStack.region },
env: { account: props.env?.account ?? parentStack.account, region: props.env?.region ?? parentStack.region },
synthesizer: new NestedStackSynthesizer(parentStack.synthesizer),
description: props.description,
crossRegionReferences: parentStack._crossRegionReferences,
Expand All @@ -134,12 +149,13 @@ export class NestedStack extends Stack {

this.parameters = props.parameters || {};

this.resource = new CfnStack(parentScope, `${id}.NestedStackResource`, {
this.resource = this.generateStackResource(parentScope, `${id}.NestedStackResource`, {
// This value cannot be cached since it changes during the synthesis phase
templateUrl: Lazy.uncachedString({ produce: () => this._templateUrl || '<unresolved>' }),
parameters: Lazy.any({ produce: () => Object.keys(this.parameters).length > 0 ? this.parameters : undefined }),
notificationArns: props.notificationArns,
timeoutInMinutes: props.timeout ? props.timeout.toMinutes() : undefined,
targetRegion: props.env?.region,
});
this.resource.applyRemovalPolicy(props.removalPolicy ?? RemovalPolicy.DESTROY);

Expand All @@ -154,6 +170,26 @@ export class NestedStack extends Stack {
this._contextualStackId = this.contextualAttribute(Aws.STACK_ID, this.resource.ref);
}

protected generateStackResource (scope: Construct, id: string, props: StackResourceProps): CfnResource {
const { targetRegion, ...stackProps } = props;
const currentRegion = Stack.of(scope).region;
if (targetRegion && targetRegion !== currentRegion) {
const crossRegionStack = new CrossRegionNestedStack(scope, id, {
...stackProps,
targetRegion,
});
this.nestedStackResourceTags = crossRegionStack.tags;
return crossRegionStack.cfnResource;
}
const cfnStack = new CfnStack(scope, id, stackProps);
this.nestedStackResourceTags =cfnStack.tags;
return cfnStack;
}

protected set nestedStackResourceTags (tagManager: TagManager) {
this._nestedStackResourceTagManager = tagManager;
}

/**
* An attribute that represents the name of the nested stack.
*
Expand Down Expand Up @@ -216,9 +252,12 @@ export class NestedStack extends Stack {
// by this class don't share the same TagManager as that of the one exposed by the `tag` property of the
// class, all the tags need to be copied to the CfnStack resource before synthesizing the resource.
// See https://github.com/aws/aws-cdk/pull/19128
Object.entries(this.tags.tagValues()).forEach(([key, value]) => {
this.resource.tags.setTag(key, value);
});
if (this._nestedStackResourceTagManager) {
const tagManager = this._nestedStackResourceTagManager;
Object.entries(this.tags.tagValues()).forEach(([key, value]) => {
tagManager.setTag(key, value);
});
}

const cfn = JSON.stringify(this._toCloudFormation());
const templateHash = crypto.createHash('sha256').update(cfn).digest('hex');
Expand Down
34 changes: 25 additions & 9 deletions packages/aws-cdk-lib/core/lib/private/refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,6 @@ function resolveValue(consumer: Stack, reference: CfnReference): IResolvable {
'Cross stack references are only supported for stacks deployed to the same account or between nested stacks and their parent stack');
}

// Stacks are in the same account, but different regions
if (producerRegion !== consumerRegion && !consumer._crossRegionReferences) {
throw new Error(
`Stack "${consumer.node.path}" cannot reference ${renderReference(reference)} in stack "${producer.node.path}". ` +
'Cross stack references are only supported for stacks deployed to the same environment or between nested stacks and their parent stack. ' +
'Set crossRegionReferences=true to enable cross region references');
}

// ----------------------------------------------------------------------
// consumer is nested in the producer (directly or indirectly)
// ----------------------------------------------------------------------
Expand Down Expand Up @@ -103,12 +95,36 @@ function resolveValue(consumer: Stack, reference: CfnReference): IResolvable {
return resolveValue(consumer, outputValue);
}

// ----------------------------------------------------------------------
// consumer is nested and not in same region as producer
// ----------------------------------------------------------------------

// Wire through a CloudFormation parameters from parent stack and then resolve
// the reference from parent stack as the consumer. Except when consuming stack
// opted in to crossRegionReferences feature.
if (!consumer._crossRegionReferences && consumer.nestedStackParent && producerRegion !== consumerRegion ) {
const parameterValue = resolveValue(consumer.nestedStackParent, reference);
return createNestedStackParameter(consumer, reference, parameterValue);
}

// ----------------------------------------------------------------------
// export/import
// ----------------------------------------------------------------------

// Stacks are in the same account, but different regions
if (producerRegion !== consumerRegion && consumer._crossRegionReferences) {
if (producerRegion !== consumerRegion) {
if ( !consumer._crossRegionReferences) {
if (consumer.nestedStackParent ) {
// Wire through a CloudFormation parameter and then resolve the reference with
// the parent stack as the consumer.
const parameterValue = resolveValue(consumer.nestedStackParent, reference);
return createNestedStackParameter(consumer, reference, parameterValue);
}
throw new Error(
`Stack "${consumer.node.path}" cannot reference ${renderReference(reference)} in stack "${producer.node.path}". ` +
'Cross stack references are only supported for stacks deployed to the same environment or between nested stacks and their parent stack. ' +
'Set crossRegionReferences=true to enable cross region references');
}
if (producerRegion === cxapi.UNKNOWN_REGION || consumerRegion === cxapi.UNKNOWN_REGION) {
throw new Error(
`Stack "${consumer.node.path}" cannot reference ${renderReference(reference)} in stack "${producer.node.path}". ` +
Expand Down
24 changes: 21 additions & 3 deletions packages/aws-cdk-lib/core/lib/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,27 @@ export abstract class Resource extends Construct implements IResource {
return mimicReference(nameAttr, {
produce: (context: IResolveContext) => {
const consumingStack = Stack.of(context.scope);

if (this.stack.account !== consumingStack.account ||
(this.stack.region !== consumingStack.region &&
const producingStack = this.stack;
let highestConsumingStack = consumingStack;
let highestProducingStack = producingStack;
while (highestConsumingStack !== highestProducingStack &&
(highestConsumingStack.nestedStackParent || highestProducingStack.nestedStackParent)
) {
if (highestProducingStack.nestedStackParent && highestProducingStack.nestedStackParent === highestConsumingStack) {
highestProducingStack = highestProducingStack.nestedStackParent;
}
if (highestConsumingStack.nestedStackParent && highestConsumingStack.nestedStackParent === highestProducingStack) {
highestConsumingStack = highestConsumingStack.nestedStackParent;
}
if (highestProducingStack.nestedStackParent) {
highestProducingStack = highestProducingStack.nestedStackParent;
}
if (highestConsumingStack.nestedStackParent) {
highestConsumingStack = highestConsumingStack.nestedStackParent;
}
}
if (producingStack.account !== consumingStack.account ||
(highestProducingStack.region !== highestConsumingStack.region &&
!consumingStack._crossRegionReferences)) {
this._enableCrossEnvironment();
return this.physicalName;
Expand Down
Loading

0 comments on commit 45bc470

Please sign in to comment.