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): present reason for cyclic references #2061

Merged
merged 7 commits into from
Mar 26, 2019
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/@aws-cdk/aws-codebuild/lib/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ export class Project extends ProjectBase {

let cache: CfnProject.ProjectCacheProperty | undefined;
if (props.cacheBucket) {
const cacheDir = props.cacheDir != null ? props.cacheDir : new cdk.AwsNoValue().toString();
const cacheDir = props.cacheDir != null ? props.cacheDir : cdk.Aws.noValue;
cache = {
type: 'S3',
location: cdk.Fn.join('/', [props.cacheBucket.bucketName, cacheDir]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export abstract class ImportedTargetGroupBase extends cdk.Construct implements I
super(scope, id);

this.targetGroupArn = props.targetGroupArn;
this.loadBalancerArns = props.loadBalancerArns || new cdk.AwsNoValue().toString();
this.loadBalancerArns = props.loadBalancerArns || cdk.Aws.noValue;
}

public export() {
Expand Down
6 changes: 3 additions & 3 deletions packages/@aws-cdk/aws-ses/lib/receipt-rule-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ export class ReceiptRuleLambdaAction implements IReceiptRuleAction {
this.props.function.addPermission(permissionId, {
action: 'lambda:InvokeFunction',
principal: new iam.ServicePrincipal('ses.amazonaws.com'),
sourceAccount: new cdk.ScopedAws().accountId
sourceAccount: cdk.Aws.accountId
});
}

Expand Down Expand Up @@ -344,7 +344,7 @@ export class ReceiptRuleS3Action implements IReceiptRuleAction {
.addServicePrincipal('ses.amazonaws.com')
.addResource(this.props.bucket.arnForObjects(`${keyPattern}*`))
.addCondition('StringEquals', {
'aws:Referer': new cdk.ScopedAws().accountId
'aws:Referer': cdk.Aws.accountId
});

this.props.bucket.addToResourcePolicy(s3Statement);
Expand All @@ -362,7 +362,7 @@ export class ReceiptRuleS3Action implements IReceiptRuleAction {
'kms:EncryptionContext:aws:ses:message-id': 'false'
},
StringEquals: {
'kms:EncryptionContext:aws:ses:source-account': new cdk.ScopedAws().accountId
'kms:EncryptionContext:aws:ses:source-account': cdk.Aws.accountId
}
});

Expand Down
23 changes: 10 additions & 13 deletions packages/@aws-cdk/cdk/lib/cfn-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,17 +126,6 @@ export abstract class CfnElement extends Construct {
}
}

import { Reference } from "./reference";

/**
* A generic, untyped reference to a Stack Element
*/
export class Ref extends Reference {
constructor(element: CfnElement) {
super({ Ref: element.logicalId }, 'Ref', element);
}
}

/**
* Base class for referenceable CloudFormation constructs which are not Resources
*
Expand All @@ -152,8 +141,16 @@ export abstract class CfnRefElement extends CfnElement {
* Returns a token to a CloudFormation { Ref } that references this entity based on it's logical ID.
*/
public get ref(): string {
return new Ref(this).toString();
return this.referenceToken.toString();
}

/**
* Return a token that will CloudFormation { Ref } this stack element
*/
protected get referenceToken(): Token {
return new CfnReference({ Ref: this.logicalId }, 'Ref', this);
}
}

import { findTokens } from "./resolve";
import { CfnReference } from "./cfn-reference";
import { findTokens } from "./resolve";
4 changes: 2 additions & 2 deletions packages/@aws-cdk/cdk/lib/cfn-parameter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CfnRefElement, Ref } from './cfn-element';
import { CfnRefElement } from './cfn-element';
import { Construct } from './construct';
import { Token } from './token';

Expand Down Expand Up @@ -99,7 +99,7 @@ export class CfnParameter extends CfnRefElement {
constructor(scope: Construct, id: string, props: CfnParameterProps) {
super(scope, id);
this.properties = props;
this.value = new Ref(this);
this.value = this.referenceToken;
this.stringValue = this.value.toString();
this.stringListValue = this.value.toList();
}
Expand Down
112 changes: 112 additions & 0 deletions packages/@aws-cdk/cdk/lib/cfn-reference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { Reference } from "./reference";

/**
* A Token that represents a CloudFormation reference to another resource
*
* If these references are used in a different stack from where they are
* defined, appropriate CloudFormation `Export`s and `Fn::ImportValue`s will be
* synthesized automatically instead of the regular CloudFormation references.
*
* Additionally, the dependency between the stacks will be recorded, and the toolkit
* will make sure to deploy producing stack before the consuming stack.
*
* This magic happens in the prepare() phase, where consuming stacks will call
* `consumeFromStack` on these Tokens and if they happen to be exported by a different
* Stack, we'll register the dependency.
*/
export class CfnReference extends Reference {
/**
* Check whether this is actually a Reference
*/
public static isCfnReferenceToken(x: Token): x is CfnReference {
return (x as any).consumeFromStack !== undefined;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you could instead use a private symbol to make this 100% reliable:

// don't export
const isReference = Symbol.for('CfnReference');
export class CfnReference extends Reference {
  // private shouldn't bother JSII
  private [isReference] = true;

  public static isCfnReferenceToken(x: Token): x is CfnReference {
    return (x as any)[isReference] === true;
  }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private doesn't bother JSII but it does bother TypeScript, which complains about unused private members.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am also not 100% sure that a private symbol will work across two different versions of this module (which is the reason we can't use instanceof).

My favorite (and very ugly still) way to do this is to add the marker at runtime:

const MARKER_KEY = '$type-marker';
const MARKER = '<uuid>';

export class MyClass {
  public static isMyClass(obj: any): obj is MyClass {
    return obj[MARKER_KEY] === MARKER;
  }

  constructor() {
    Object.defineProperty(this, MARKER_KEY, {
      value: MARKER,
      enumerable: false
    });
  }
}

Yeah, horrible. TypeScript, can you make this easier?

Copy link
Contributor

@sam-goodwin sam-goodwin Mar 25, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will work across two different versions because Symbol.for('key') stores the symbol in the global symbol registry by name. It would not work if you were to just call Symbol(), which is unique for every call. By private, I mean hiding it from JSII.

Your UUID approach is an emulation of the global Symbol registry which was designed to solve this problem.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use this to avoid some of the ugly duck typing we do - mark up the core constructs with symbols and maybe even generate some for L1. Module augmentation may even allow us to generate for our L2 later.

Create a symbol for each type that would be checked by name in a nominal type system.

Copy link
Contributor Author

@rix0rrr rix0rrr Mar 25, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, Symbol.for() will fix it. Although I do wonder what the point of making it a Symbol is, then, since we lose the uniqueness value of it. I guess that it can't conflict with a naive string.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On top of avoiding accidental conflicts, Symbols are not considered properties so it won't show up when enumerating an object's keys. Basically, it's cleaner and simpler.

}

/**
* What stack this Token is pointing to
*/
private readonly producingStack?: Stack;

/**
* The Tokens that should be returned for each consuming stack (as decided by the producing Stack)
*/
private readonly replacementTokens: Map<Stack, Token>;

private readonly originalDisplayName: string;

constructor(value: any, displayName: string, target: Construct) {
if (typeof(value) === 'function') {
throw new Error('Reference can only hold CloudFormation intrinsics (not a function)');
}
// prepend scope path to display name
super(value, `${target.node.id}.${displayName}`, target);
this.originalDisplayName = displayName;
this.replacementTokens = new Map<Stack, Token>();

this.producingStack = target.node.stack;
}

public resolve(context: ResolveContext): any {
// If we have a special token for this consuming stack, resolve that. Otherwise resolve as if
// we are in the same stack.
const token = this.replacementTokens.get(context.scope.node.stack);
if (token) {
return token.resolve(context);
} else {
return super.resolve(context);
}
}

/**
* Register a stack this references is being consumed from.
*/
public consumeFromStack(consumingStack: Stack, consumingConstruct: IConstruct) {
if (this.producingStack && this.producingStack !== consumingStack && !this.replacementTokens.has(consumingStack)) {
// We're trying to resolve a cross-stack reference
consumingStack.addDependency(this.producingStack, `${consumingConstruct.node.path} -> ${this.target.node.path}.${this.originalDisplayName}`);
this.replacementTokens.set(consumingStack, this.exportValue(this, consumingStack));
}
}

/**
* Export a Token value for use in another stack
*
* Works by mutating the producing stack in-place.
*/
private exportValue(tokenValue: Token, consumingStack: Stack): Token {
const producingStack = this.producingStack!;

if (producingStack.env.account !== consumingStack.env.account || producingStack.env.region !== consumingStack.env.region) {
throw new Error('Can only reference cross stacks in the same region and account.');
}

// 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 exportsName = 'Exports';
let stackExports = producingStack.node.tryFindChild(exportsName) as Construct;
if (stackExports === undefined) {
stackExports = new Construct(producingStack, exportsName);
}

// Ensure a singleton CfnOutput for this value
const resolved = producingStack.node.resolve(tokenValue);
const id = 'Output' + JSON.stringify(resolved);
let output = stackExports.node.tryFindChild(id) as CfnOutput;
if (!output) {
output = new CfnOutput(stackExports, id, { value: tokenValue });
}

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

}

import { CfnOutput } from "./cfn-output";
import { Construct, IConstruct } from "./construct";
import { Stack } from "./stack";
import { ResolveContext, Token } from "./token";
4 changes: 2 additions & 2 deletions packages/@aws-cdk/cdk/lib/cfn-resource.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import cxapi = require('@aws-cdk/cx-api');
import { CfnCondition } from './cfn-condition';
import { Construct, IConstruct } from './construct';
import { Reference } from './reference';
import { CreationPolicy, DeletionPolicy, UpdatePolicy } from './resource-policy';
import { TagManager } from './tag-manager';
import { capitalizePropertyNames, ignoreEmpty, PostResolveToken } from './util';
// import required to be here, otherwise causes a cycle when running the generated JavaScript
// tslint:disable-next-line:ordered-imports
import { CfnRefElement } from './cfn-element';
import { CfnReference } from './cfn-reference';

export interface CfnResourceProps {
/**
Expand Down Expand Up @@ -133,7 +133,7 @@ export class CfnResource extends CfnRefElement {
* @param attributeName The name of the attribute.
*/
public getAtt(attributeName: string) {
return new Reference({ 'Fn::GetAtt': [this.logicalId, attributeName] }, attributeName, this);
return new CfnReference({ 'Fn::GetAtt': [this.logicalId, attributeName] }, attributeName, this);
}

/**
Expand Down
29 changes: 18 additions & 11 deletions packages/@aws-cdk/cdk/lib/construct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ export class ConstructNode {
private readonly _children: { [name: string]: IConstruct } = { };
private readonly context: { [key: string]: any } = { };
private readonly _metadata = new Array<MetadataEntry>();
private readonly references = new Set<Token>();
private readonly references = new Set<Reference>();
private readonly dependencies = new Set<IDependable>();

/** Will be used to cache the value of ``this.stack``. */
private _stack?: Stack;
private _stack?: import('./stack').Stack;

/**
* If this is set to 'true'. addChild() calls for this construct and any child
Expand Down Expand Up @@ -91,11 +91,13 @@ export class ConstructNode {
/**
* The stack the construct is a part of.
*/
public get stack(): Stack {
public get stack(): import('./stack').Stack {
// Lazy import to break cyclic import
const stack: typeof import('./stack') = require('./stack');
return this._stack || (this._stack = _lookStackUp(this));

function _lookStackUp(_this: ConstructNode) {
if (Stack.isStack(_this.host)) {
function _lookStackUp(_this: ConstructNode): import('./stack').Stack {
if (stack.Stack.isStack(_this.host)) {
return _this.host;
}
if (!_this.scope) {
Expand Down Expand Up @@ -471,7 +473,7 @@ export class ConstructNode {
*/
public recordReference(...refs: Token[]) {
for (const ref of refs) {
if (ref.isReference) {
if (Reference.isReferenceToken(ref)) {
this.references.add(ref);
}
}
Expand All @@ -480,12 +482,12 @@ export class ConstructNode {
/**
* Return all references of the given type originating from this node or any of its children
*/
public findReferences(): Token[] {
const ret = new Set<Token>();
public findReferences(): OutgoingReference[] {
const ret = new Set<OutgoingReference>();

function recurse(node: ConstructNode) {
for (const ref of node.references) {
ret.add(ref);
for (const reference of node.references) {
ret.add({ source: node.host, reference });
}

for (const child of node.children) {
Expand Down Expand Up @@ -729,5 +731,10 @@ export interface Dependency {
target: IConstruct;
}

export interface OutgoingReference {
source: IConstruct;
reference: Reference;
}

// Import this _after_ everything else to help node work the classes out in the correct order...
import { Stack } from './stack';
import { Reference } from './reference';
1 change: 1 addition & 0 deletions packages/@aws-cdk/cdk/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export * from './logical-id';
export * from './cfn-mapping';
export * from './cfn-output';
export * from './cfn-parameter';
export * from './cfn-reference';
export * from './pseudo';
export * from './cfn-resource';
export * from './resource-policy';
Expand Down
Loading