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

AWSCL: External resource references #1546

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all 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
400 changes: 400 additions & 0 deletions design/external-references.md

Large diffs are not rendered by default.

19 changes: 2 additions & 17 deletions packages/@aws-cdk/cdk/lib/cloudformation/cfn-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,24 +85,10 @@ export class CfnReference extends Token {
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 Output for this value
const resolved = producingStack.node.resolve(tokenValue);
const id = 'Output' + JSON.stringify(resolved);
let output = stackExports.node.tryFindChild(id) as Output;
if (!output) {
output = new Output(stackExports, id, { value: tokenValue });
}
const id = JSON.stringify(resolved);
const output = producingStack.exportString(`AutoExport-${id}`, tokenValue.toString());

// We want to return an actual FnImportValue Token here, but Fn.importValue() returns a 'string',
// so construct one in-place.
Expand All @@ -112,5 +98,4 @@ export class CfnReference extends Token {
}

import { Construct } from "../core/construct";
import { Output } from "./output";
import { Stack } from "./stack";
133 changes: 127 additions & 6 deletions packages/@aws-cdk/cdk/lib/cloudformation/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import cxapi = require('@aws-cdk/cx-api');
import { App } from '../app';
import { Construct, IConstruct } from '../core/construct';
import { Environment } from '../environment';
import { CloudFormationImportContextProvider } from '../serialization/import-context-provider';
import { ExportSerializationContext, ImportDeserializationContext } from '../serialization/import-export';
import { IDeserializationContext, ISerializable, SerializationOptions } from '../serialization/serialization';
import { ArnComponents, arnFromComponents, parseArn } from './arn';
import { CfnReference } from './cfn-tokens';
import { Fn } from './fn';
import { HashedAddressingScheme, IAddressingScheme, LogicalIDs } from './logical-id';

export interface StackProps {
Expand Down Expand Up @@ -237,6 +242,109 @@ export class Stack extends Construct {
return Array.from(this.stackDependencies.values());
}

/**
* Returns a deserialization context for importing an object which was exported under the
* specified export name.
*
* @param exportName The export name specified when the object was exported.
* @param options Import options
*/
public importObject(exportName: string, options?: ImportOptions): IDeserializationContext {
return new ImportDeserializationContext(this, exportName, options);
}

/**
* Produces CloudFormation outputs for a serializable object under the specified
* export name.
*
* @param exportName The export name prefix for all the outputs.
* @param obj The object to serialize
*/
public exportObject(exportName: string, obj: ISerializable): void {
const ctx = new ExportSerializationContext(this, exportName);
obj.serialize(ctx);
}

/**
* Imports a string from another stack in the same account/region which was
* exported under the specified export name.
* @param exportName The export name under which the string was exported
* @param options Import options
*/
public importString(exportName: string, options: ImportOptions = { }): string {
const stack = this;
const resolve = options.resolve === undefined ? ResolveType.Synthesis : options.resolve;
const weak = options.weak === undefined ? false : options.weak;

if (resolve === ResolveType.Deployment && weak) {
throw new Error(`Deployment-time import resolution cannot be "weak"`);
}

switch (resolve) {
case ResolveType.Deployment:
return Fn.importValue(exportName);
case ResolveType.Synthesis:
const value = new CloudFormationImportContextProvider(stack, { exportName }).parameterValue();
if (!weak) {
stack.addStrongReference(exportName);
}

return value;
}
}

/**
* Exports a string value under an export name.
* @param exportName The export name under which to export the string. Export
* names must be unique within the account/region.
* @param value The value to export.
* @param options Export options, such as description.
*/
public exportString(exportName: string, value: string, options: ExportOptions = { }): Output {
let output = this.node.tryFindChild(exportName) as Output;
if (!output) {
output = new Output(this.exportsScope, exportName, {
description: options.description,
export: exportName,
value
});
} else {
if (output.value !== value) {
// tslint:disable-next-line:max-line-length
throw new Error(`Trying to export ${exportName}=${value} but there is already an export with a similar name and a different value (${output.value})`);
}
}
return output;
}

/**
* Adds a strong reference from this stack to a specific export name.
*
* Technically, if the stack has strong references, a WaitCondition resource
* will be synthesized, and a metadata entry with Fn::ImportValue will be
* added for each export name.
*
* @param exportName The name of the CloudFormation export to reference
*/
public addStrongReference(exportName: string) {
const id = 'StrongReferences8A180F';
let strongRef = this.node.tryFindChild(id) as Resource;
if (!strongRef) {
strongRef = new Resource(this, id, { type: 'AWS::CloudFormation::WaitCondition' });
}

strongRef.options.metadata = strongRef.options.metadata || { };
strongRef.options.metadata[exportName] = Fn.importValue(exportName);
}

private get exportsScope() {
const exists = this.node.tryFindChild('Exports') as Construct;
if (exists) {
return exists;
}
return new Construct(this, 'Exports');
}

/**
* The account in which this stack is defined
*
Expand Down Expand Up @@ -388,7 +496,7 @@ export class Stack extends Construct {
protected prepare() {
// References
for (const ref of this.node.findReferences()) {
if (CfnReference.isCfnReference(ref)) {
if (CfnReference.isInstance(ref)) {
ref.consumeFromStack(this);
}
}
Expand Down Expand Up @@ -505,11 +613,18 @@ function stackElements(node: IConstruct, into: StackElement[] = []): StackElemen
return into;
}

// These imports have to be at the end to prevent circular imports
import { ArnComponents, arnFromComponents, parseArn } from './arn';
import { Aws } from './pseudo';
import { Resource } from './resource';
import { StackElement } from './stack-element';
export interface ExportOptions {
description?: string;
}

export interface ImportOptions {
resolve?: ResolveType;
weak?: boolean;
}

export enum ResolveType {
Synthesis,
Deployment

/**
* Find all resources in a set of constructs
Expand All @@ -521,3 +636,9 @@ function findResources(roots: Iterable<IConstruct>): Resource[] {
}
return ret;
}

// These imports have to be at the end to prevent circular imports
import { ArnComponents, arnFromComponents, parseArn } from './arn';
import { Aws } from './pseudo';
import { Resource } from './resource';
import { StackElement } from './stack-element';
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 @@ -4,6 +4,7 @@ export * from './aspects/tag-aspect';
export * from './core/construct';
export * from './core/tokens';
export * from './core/tag-manager';
export * from './serialization/serialization';
export * from './core/dependency';

export * from './cloudformation/cloudformation-json';
Expand Down
29 changes: 29 additions & 0 deletions packages/@aws-cdk/cdk/lib/serialization/import-context-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import cxapi = require('@aws-cdk/cx-api');
import { ContextProvider } from '../context';
import { Construct } from '../core/construct';

export interface CloudFormationImportContextProviderProps {
/**
* The name of the export to resolve
*/
exportName: string;
}
/**
* Context provider that will read values from the SSM parameter store in the indicated account and region
*/
export class CloudFormationImportContextProvider {
private readonly provider: ContextProvider;
private readonly exportName: string;

constructor(scope: Construct, props: CloudFormationImportContextProviderProps) {
this.provider = new ContextProvider(scope, cxapi.CLOUDFORMATION_IMPORT_PROVIDER, props);
this.exportName = props.exportName;
}

/**
* Return the SSM parameter string with the indicated key
*/
public parameterValue(defaultValue = `dummy-imported-value-for-${this.exportName}`): any {
return this.provider.getStringValue(defaultValue);
}
}
102 changes: 102 additions & 0 deletions packages/@aws-cdk/cdk/lib/serialization/import-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Fn } from '../cloudformation/fn';
import { ImportOptions, ResolveType, Stack } from '../cloudformation/stack';
import { Construct } from '../core/construct';
import { unresolved } from '../core/tokens';
import { DeserializationOptions, IDeserializationContext, ISerializable, ISerializationContext, SerializationOptions } from './serialization';

const LIST_SEP = '||';

export class ExportSerializationContext implements ISerializationContext {
constructor(
private readonly stack: Stack,
private readonly exportName: string,
private readonly options: SerializationOptions = { }) {
}

public writeString(key: string, value: string, options: SerializationOptions = { }): void {
let description = this.options.description;
if (options.description) {
if (!description) {
description = options.description;
} else {
description += ' - ' + options.description;
}
}
this.stack.exportString(this.exportNameForKey(key), value, { description });
}

public writeStringList(key: string, list: string[], options?: SerializationOptions): void {
// we use Fn.join instead of Array.join in case "list" is a token.
const value = Fn.join(LIST_SEP, list);
this.writeString(key, value, options);
}

public writeObject(key: string, obj: ISerializable, options?: SerializationOptions): void {
const ctx = new ExportSerializationContext(this.stack, this.exportNameForKey(key), options);
obj.serialize(ctx);
}

private exportNameForKey(key: string) {
return `${this.exportName}-${key}`;
}
}

export class ImportDeserializationContext implements IDeserializationContext {
private resolve: ResolveType;
private weak: boolean;

constructor(
private readonly stack: Stack,
private readonly exportName: string,
private readonly importOptions: ImportOptions = { }) {

this.resolve = importOptions.resolve === undefined ? ResolveType.Synthesis : importOptions.resolve;
this.weak = importOptions.weak === undefined ? false : importOptions.weak;

if (this.resolve === ResolveType.Deployment && this.weak) {
throw new Error(`Deployment-time import resolution cannot be "weak"`);
}
}

public get scope() {
const exists = this.stack.node.tryFindChild('Imports') as Construct;
if (exists) {
return exists;
}

return new Construct(this.stack, 'Imports');
}

public get id() {
return this.exportName;
}

public readString(key: string, options: DeserializationOptions = { }): string {
const allowUnresolved = options.allowUnresolved === undefined ? true : false;
const exportName = this.exportNameForKey(key);
const value = this.stack.importString(exportName, this.importOptions);

if (!allowUnresolved && unresolved(value)) {
throw new Error(`Imported value for export "${exportName}" is an unresolved token and "allowUnresolved" is false`);
}

return value;
}

public readStringList(key: string, options: DeserializationOptions = { }): string[] {
if (this.resolve === ResolveType.Deployment) {
throw new Error(`Cannot deserialize a string list for export "${this.exportName}-${key}" using deploy-time resolution`);
}

// we are overriding "allowUnresolved" to "false" because we can't split an unresolved list.
return this.readString(key, { ...options, allowUnresolved: false }).split(LIST_SEP);
}

public readObject(key: string): IDeserializationContext {
return new ImportDeserializationContext(this.stack, this.exportNameForKey(key), this.importOptions);
}

private exportNameForKey(key: string) {
return `${this.exportName}-${key}`;
}
}
Loading