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(cdk): transparent cross-stack references #1436

Merged
merged 44 commits into from
Jan 9, 2019

Conversation

rix0rrr
Copy link
Contributor

@rix0rrr rix0rrr commented Dec 26, 2018

It is now no longer necessary to use export() and import() when
sharing constructs between two Stacks inside the same CDK app.
Instead, objects defined in one stack can be used directly in another
stack.

The CDK will detect when an attribute (such as an ARN, ID or URL) of
such an object is used in a different stack, and will automatically
create the required Export in the producing stack and insert the
corresponding Fn::ImportValue in the consuming stack.

BREAKING CHANGE: if you are using export() and import() to share
constructs between stacks, you can stop doing that, instead of
FooImportProps accept an IFoo directly on the consuming stack,
and use that object as usual. ArnUtils.fromComponents() and
ArnUtils.parse() have been moved onto Stack. All CloudFormation
pseudo-parameter (such as AWS::AccountId etc) are now also
accessible via Stack, as stack.accountId etc. resolve() has
been moved to this.node.resolve(). CloudFormationJSON.stringify()
has been moved to this.node.stringifyJson(). validate() now
should be protected.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license.

rix0rrr and others added 9 commits September 29, 2018 14:17
Introduce context in resolve(), so that StackAwareToken can do something
else if it's a Token from a different stack.

Introduce a two-phase Freeze protocol which is a definite moment at
which all lazy tokens must be resolved (so that the StackAwareTokens
can build cross-Stack links).

Change lock() into the freezing protocol, remove unlock as this was
a speculative feature that is unused.
Copy link
Contributor

@eladb eladb left a comment

Choose a reason for hiding this comment

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

Pseudo values: I agree that they should just be members of Stack, and we should stop this little game to try and make stack (and tokens) cloudformation-agnostic. I think we've crossed that river long ago, and we should just accept it. When we do #798, so Construct is also stack-aware, the usage will be really nice: this.stack.region will just return the region (and can be even smart about whether we actually know the region as concrete value or use { Ref: AWS::Region } if not.

const account = components.account == null
? new AwsAccountId()
: components.account;
public static fromComponents(components: ArnComponents, anchor?: Construct): string {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we also make these ARN utils part of Stack, so: this.stack.formatArn and this.stack.parseArn

Copy link
Contributor

Choose a reason for hiding this comment

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

I would honestly prefer Arn.format and Arn.parse - that makes sense to me intuitively. Looking for string formatting capability on Stack sounds weird.

Copy link
Contributor

Choose a reason for hiding this comment

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

The problem is that these methods now must be anchored within a stack, so it makes sense to have them as utilities of the stack in my mind.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ArnUtils.parse does not need the stack, only ArnUtils.fromComponents(). To maintain symmetry, we should put both on Stack then, but I also don't know that it's the most discoverable or appropriate place.

Copy link
Contributor

Choose a reason for hiding this comment

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

+1 for symmetry. As for discoverability - I'd argue that associating those with the stack might make them more discoverable than they are now, which is basically a free floating class that people need to learn about without any way to discover through IDE content assist.

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh right. Symmetry makes it worth moving both to Stack. I'm still somewhat wary of the namespace pollution, but every mitigation I can think of risks being immensely confusing, so... Good with that.

packages/@aws-cdk/cdk/lib/core/tokens.ts Outdated Show resolved Hide resolved
packages/@aws-cdk/cdk/lib/cloudformation/stack.ts Outdated Show resolved Hide resolved
packages/@aws-cdk/cdk/lib/cloudformation/stack.ts Outdated Show resolved Hide resolved
/**
* Export a Token value for use in another stack
*/
public exportValue(tokenValue: Token, consumingStack: Stack): Token {
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if this should be "The Way To Export" values from a stack, in which case we should probably expose the full surface area of "Export" such as type, export name, description, etc.

* Export a Token value for use in another stack
*/
public exportValue(tokenValue: Token, consumingStack: Stack): Token {
if (this.env.account !== consumingStack.env.account || this.env.region !== consumingStack.env.region) {
Copy link
Contributor

Choose a reason for hiding this comment

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

My intuition is that this check should happen at the consuming side, not the exporting side and that export should not need to know who is going to consume it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Exporting cross-account/region mutates the exporting stack differently than exporting in-account/in-region, so the exporting stack still needs to know what is up.

In-env: Export/Fn::ImportValue
Out-of-env: depending on how we choose to implement, either Output+IAM::Role/CFN::CustomResource (CFN native) or Output/Parameter+Metadata (toolkit assisted)

packages/@aws-cdk/cdk/lib/cloudformation/stack.ts Outdated Show resolved Hide resolved
packages/@aws-cdk/cdk/lib/core/tokens.ts Outdated Show resolved Hide resolved
- stack.accountId
- stack.region
- stack.partition
- stack.urlSuffix

etc.

BREAKING CHANGE: pseudo parameters have disappeared and should
now be retrieved as attributes of Stack. Some account-granting
functions now have an additional parameter, 'anchor', in which you
should pass the current scope (typically 'this').
Copy link
Contributor

@eladb eladb left a comment

Choose a reason for hiding this comment

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

I still feel there's room for simplification/generalization here... I know this might be a bigger change, but now is our time to rethink about this...

Why do we even need resolve as a free floating API at all besides for testing purposes?

I agree that we need two distinct phases, but I am wondering why we can't implement them generically without requiring each node to cooperate. Basically, it requires invoking toCloudFormation twice on each stack element: in the first pass, just to find tokens in the result (without substitution), and let them hook into the "relationship" (i.e. add exports to the producing stack). In the second pass, we just synthesize and substitute all tokens with their resolved values.

  1. Find all stack elements in the tree

  2. Invoke toCloudFormation on each element

  3. Find all values with unresolved tokens in the template

  4. If the value is "a pure token", resolve and replace the JSON node

  5. If the value is a "string with tokens", resolve and produce an FnJoin statement (fail if the resolved value is not a string or an intrinsic function).

packages/@aws-cdk/aws-apigateway/lib/restapi.ts Outdated Show resolved Hide resolved
@@ -73,7 +73,7 @@ export class AlarmWidget extends ConcreteWidget {
properties: {
view: 'timeSeries',
title: this.props.title,
region: this.props.region || new AwsRegion(),
region: this.props.region || new cdk.Token({ Ref: 'AWS::Region' }),
Copy link
Contributor

Choose a reason for hiding this comment

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

Any reason not to use stack.region here?

Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if we should still provide something like Aws.region and Aws.accountId for people to be able to explicitly use these pseudo parameters? Or even Stack.region and Stack.accountId

* construct tree, this class takes an anchor parameter; the pseudo parameter
* values can be obtained as properties from an anchored object.
*/
export class Aws {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we really need this as well as the stack.xxx properties?

/**
* Export a Token value for use in another stack
*/
public exportValue(tokenValue: Token, consumingStack: Stack): Token {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we somehow hide this method so it won't confuse people. Technically IIUC people are not supposed to use it directly.

/**
* In a consuming context, potentially substitute this Token with a different one
*/
public substituteToken(consumingStack: Stack): Token {
Copy link
Contributor

Choose a reason for hiding this comment

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

Would really love to explore the option to generalize this such that tokens can hook into their consumers (see #1433).

@rix0rrr
Copy link
Contributor Author

rix0rrr commented Jan 3, 2019

Here's my plan for generalizing this so that it can also subsume our current bind() mechanism convention, and potentially other things in the future:

GIST

  • Tokens can (optionally) be generalized to References, by overriding a referenceType public property with an identifying string for the type of reference.
  • Constructs can declare what types of references they want to take action on by calling this.node.notifyReferenceType(refType) in the constructor. Usually, declaring that you consume a certain reference type goes hand-in-hand with more implementation responsibilities such as implementing a certain interface (or being of a certain type). Due to lack of generics, there is no way for us to enforce this at compile time, so we'll have to duck-type/runtime-check it.
  • If any reference of the given type(s) is/are used in the construct or one of its children, we'll call both:
    • construct.notifyReferences(refToken), construct should override this method which does nothing by default
    • refToken.notifyReferencedBy(construct), token should override this method which does nothing by default
    • We'll give both the consuming and consumed side of the references a chance to act upon the linkage. We'll assume that at this point, the code will know what the types of objects involved are and will be able to downcast/invoke whatever it needs at both sides.

HOW DOES THIS HELP

  • Cross-stack references: StackAwareCloudFormationToken will be renamed CloudFormationReferenceToken, will be a referenceType "cloudFormationReference" that will be consumed by Stack. In the notifyReferencedBy will invoke the appropriate logic on its anchored Stack (exporting itself). In the consuming Stack.notifyReferneces the Stack ordering dependency will be recorded. During resolve() from a different stack it will resolve to a {Fn::ImportValue}.
  • Bind mechanism: we'll make new subclasses of Token that will do the logic that's currently in bind() in notifyReferencedBy(), such as adding permissions to the consumer.

CHANGES

Construct

  • resolve() goes here so that it's always clear in what context we are resolving a Token
  • Add node.notifyReferenceType() to request to be notified of a given reference.
  • Add protected notifyReferences() which can be overridden.
  • Add node.reportReference() to report references that cannot be autodetected because they don't end up in CFN output (happens in FunctionBase -> IEventSource).

Token

  • Add public referenceType?: string.
  • Add protected consumedBy() which can be overridden.

StackElement

  • collectReferences(); combines toCloudFormation() with a new feature of resolve() to collect all Tokens that are references, to enable implicit reporting of references that end up in CFN output (90% use case).

App

  • Add resolveReferences() as an additional synthesis step.

@eladb
Copy link
Contributor

eladb commented Jan 3, 2019

Not sure I understand the motivation behind constructs notifying about which reference types they consume instead of notifying about concrete references. What I imagined is basically a virtual method on Construct that allows a construct to indicate which references it consumed (implemented automatically by StackElement to collect references from toCloudFormation output).

I am also not clear as to why we need the "double notification". If the construct is already notifying the framework that it's consuming a reference, why do we need the system to also notify the construct about the same thing? Wouldn't it be sufficient to just notify the reference that it's being consumed in some scope?

Some alternative naming preferences:

@rix0rrr
Copy link
Contributor Author

rix0rrr commented Jan 3, 2019

I guess this confusion is caused by naming. One is:

  • Upon creation, I'll tell the system which types of references I'm interested in.

The other one is:

  • Later on, during synthesis, the system will tell me which actual reference instances were found.

Copy link
Contributor

@eladb eladb left a comment

Choose a reason for hiding this comment

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

Wow! Looks great.

// if hash components were added to the deployment, we use them to calculate
// a logical ID for the deployment resource.
if (this.hashComponents.length === 0) {
this.lazyLogicalId = this.originalLogicalId;
} else {
const md5 = crypto.createHash('md5');
this.hashComponents
.map(c => cdk.resolve(c))
.map(c => this.node.resolve(c))
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 wondering if it would make sense to only expose resolve during prepare and synthesis by passing in something like SynthesisContext to the prepare method? This will sure people don't abuse this capability before the tree is ready.

Copy link
Contributor

Choose a reason for hiding this comment

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

That's interesting - /me likey.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

@rix0rrr thanks for the due diligence. It's so much nicer to have these discussions over actual examples.

Let's punt this change for now for the sake of progress, but I think we should still explore this. The examples you provided are interesting and actually expose some issues, which is precisely

  • Error messages: the practice of JSON.stringify(resolve) feels like a lucky hack anyway, and only works when specific construct authors are aware that a value might be a token. I think a more robust solution to this problem would be to improve the string representation of tokens of resource references (GetAtt/Refs) in strings so they will be less intimidating. For instance, one can argue that something like ${{ "Fn::GetAtt": [ Logical, "Attribute" ]}} can be used for GetAtt tokens (and this can be used as the map key to the token object.

  • I'd argue that this use case should actually query unresolved and then just type-check. There is no need to actually resolve the values:

https://github.com/awslabs/aws-cdk/blob/3a0d5b5cf98e3cbcbd6139ef1e07135b4bece5f7/packages/@aws-cdk/aws-s3/lib/util.ts#L35

  • As for lazy evaluation: totally agree. Lazy evaluation is basically a hook into the synthesis phase. If you recall, I proposed in the past to stop allowing "free floating" lazy tokens have have something like: this.node.onSynthesize(synthesisContext => bla), so yes, passing synthesisContext to such a lazy handler makes total sense to me.

packages/@aws-cdk/aws-apigateway/lib/deployment.ts Outdated Show resolved Hide resolved
packages/@aws-cdk/aws-apigateway/lib/integrations/aws.ts Outdated Show resolved Hide resolved
packages/@aws-cdk/aws-apigateway/lib/integrations/aws.ts Outdated Show resolved Hide resolved
packages/@aws-cdk/cdk/lib/core/tokens/cfn-tokens.ts Outdated Show resolved Hide resolved
packages/@aws-cdk/cdk/lib/core/tokens/cfn-tokens.ts Outdated Show resolved Hide resolved
packages/@aws-cdk/cdk/lib/core/tokens/cfn-tokens.ts Outdated Show resolved Hide resolved
packages/@aws-cdk/cdk/lib/core/tokens/token.ts Outdated Show resolved Hide resolved
packages/@aws-cdk/cdk/lib/runtime.ts Outdated Show resolved Hide resolved
@rix0rrr rix0rrr self-assigned this Jan 8, 2019
@rix0rrr rix0rrr merged commit d7371f0 into master Jan 9, 2019
@rix0rrr rix0rrr deleted the huijbers/x-stack-references branch January 9, 2019 08:17
@eladb
Copy link
Contributor

eladb commented Jan 9, 2019

Hallelujah!

@eladb eladb changed the title feat(cdk): transparent cross-stack references (WIP) feat(cdk): transparent cross-stack references Feb 11, 2019
@NGL321 NGL321 added the contribution/core This is a PR that came from AWS. label Sep 27, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
contribution/core This is a PR that came from AWS.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants