From f5ebacde38ae73b2fb404bcc6b4d3eb9012b356f Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Mon, 8 Jun 2020 11:53:07 +0300 Subject: [PATCH] chore(core): Stages (#8423) Stages are self-contained application units that synthesize as a cloud assembly. This change centralizes prepare + synthesis logic into the stage level and changes `App` to extend `Stage`. Once `stage.synth()` is called, the stage becomes (practically) immutable. This means that subsequent synths will return the same output. The cloud assembly produced by stages is nested as an artifact inside another cloud assembly (either the App's top-level assembly) or a child. Authors: @rix0rrr, @eladb ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- allowed-breaking-changes.txt | 6 +- .../lib/artifact-schema.ts | 22 +- .../cloud-assembly-schema/lib/schema.ts | 5 + .../schema/cloud-assembly.schema.json | 21 ++ .../schema/cloud-assembly.version.json | 2 +- .../scripts/update-schema.sh | 2 +- packages/@aws-cdk/core/README.md | 64 +++- packages/@aws-cdk/core/lib/app.ts | 46 +-- .../@aws-cdk/core/lib/construct-compat.ts | 25 +- packages/@aws-cdk/core/lib/deps.ts | 18 +- packages/@aws-cdk/core/lib/index.ts | 1 + .../@aws-cdk/core/lib/private/prepare-app.ts | 25 +- packages/@aws-cdk/core/lib/private/refs.ts | 6 +- .../@aws-cdk/core/lib/private/synthesis.ts | 170 ++++++++++ packages/@aws-cdk/core/lib/stack.ts | 187 ++++++++--- packages/@aws-cdk/core/lib/stage.ts | 201 ++++++++++++ packages/@aws-cdk/core/test/test.stage.ts | 304 ++++++++++++++++++ .../cx-api/design/NESTED_ASSEMBLIES.md | 93 ++++++ packages/@aws-cdk/cx-api/jest.config.js | 10 +- .../asset-manifest-artifact.ts | 4 +- .../cloudformation-artifact.ts | 24 +- .../nested-cloud-assembly-artifact.ts | 49 +++ .../{ => artifacts}/tree-cloud-artifact.ts | 4 +- .../@aws-cdk/cx-api/lib/cloud-artifact.ts | 11 +- .../@aws-cdk/cx-api/lib/cloud-assembly.ts | 62 +++- packages/@aws-cdk/cx-api/lib/index.ts | 7 +- .../test/cloud-assembly-builder.test.ts | 34 +- .../@aws-cdk/cx-api/test/placeholders.test.ts | 29 +- 28 files changed, 1279 insertions(+), 153 deletions(-) create mode 100644 packages/@aws-cdk/core/lib/private/synthesis.ts create mode 100644 packages/@aws-cdk/core/lib/stage.ts create mode 100644 packages/@aws-cdk/core/test/test.stage.ts create mode 100644 packages/@aws-cdk/cx-api/design/NESTED_ASSEMBLIES.md rename packages/@aws-cdk/cx-api/lib/{ => artifacts}/asset-manifest-artifact.ts (90%) rename packages/@aws-cdk/cx-api/lib/{ => artifacts}/cloudformation-artifact.ts (89%) create mode 100644 packages/@aws-cdk/cx-api/lib/artifacts/nested-cloud-assembly-artifact.ts rename packages/@aws-cdk/cx-api/lib/{ => artifacts}/tree-cloud-artifact.ts (83%) diff --git a/allowed-breaking-changes.txt b/allowed-breaking-changes.txt index e6bdc57ed11ae..e174e6ace55d6 100644 --- a/allowed-breaking-changes.txt +++ b/allowed-breaking-changes.txt @@ -1,5 +1,9 @@ +# Actually adding any artifact type will break the load() type signature because I could have written +# const x: A | B = Manifest.load(); +# and that won't typecheck if Manifest.load() adds a union arm and now returns A | B | C. +change-return-type:@aws-cdk/cloud-assembly-schema.Manifest.load + removed:@aws-cdk/core.BootstraplessSynthesizer.DEFAULT_ASSET_PUBLISHING_ROLE_ARN removed:@aws-cdk/core.DefaultStackSynthesizer.DEFAULT_ASSET_PUBLISHING_ROLE_ARN removed:@aws-cdk/core.DefaultStackSynthesizerProps.assetPublishingExternalId removed:@aws-cdk/core.DefaultStackSynthesizerProps.assetPublishingRoleArn - diff --git a/packages/@aws-cdk/cloud-assembly-schema/lib/artifact-schema.ts b/packages/@aws-cdk/cloud-assembly-schema/lib/artifact-schema.ts index 866a1a6553c38..dd1337d6d5e52 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/lib/artifact-schema.ts +++ b/packages/@aws-cdk/cloud-assembly-schema/lib/artifact-schema.ts @@ -84,7 +84,27 @@ export interface TreeArtifactProperties { readonly file: string; } +/** + * Artifact properties for nested cloud assemblies + */ +export interface NestedCloudAssemblyProperties { + /** + * Relative path to the nested cloud assembly + */ + readonly directoryName: string; + + /** + * Display name for the cloud assembly + * + * @default - The artifact ID + */ + readonly displayName?: string; +} + /** * Properties for manifest artifacts */ -export type ArtifactProperties = AwsCloudFormationStackProperties | AssetManifestProperties | TreeArtifactProperties; \ No newline at end of file +export type ArtifactProperties = AwsCloudFormationStackProperties +| AssetManifestProperties +| TreeArtifactProperties +| NestedCloudAssemblyProperties; \ No newline at end of file diff --git a/packages/@aws-cdk/cloud-assembly-schema/lib/schema.ts b/packages/@aws-cdk/cloud-assembly-schema/lib/schema.ts index 1c4efd0cded5d..1d351364e019d 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/lib/schema.ts +++ b/packages/@aws-cdk/cloud-assembly-schema/lib/schema.ts @@ -25,6 +25,11 @@ export enum ArtifactType { * Manifest for all assets in the Cloud Assembly */ ASSET_MANIFEST = 'cdk:asset-manifest', + + /** + * Nested Cloud Assembly + */ + NESTED_CLOUD_ASSEMBLY = 'cdk:cloud-assembly', } /** diff --git a/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json b/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json index 73319145f8196..8c3e58485b12b 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json +++ b/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json @@ -72,6 +72,9 @@ }, { "$ref": "#/definitions/TreeArtifactProperties" + }, + { + "$ref": "#/definitions/NestedCloudAssemblyProperties" } ] } @@ -85,6 +88,7 @@ "enum": [ "aws:cloudformation:stack", "cdk:asset-manifest", + "cdk:cloud-assembly", "cdk:tree", "none" ], @@ -331,6 +335,23 @@ "file" ] }, + "NestedCloudAssemblyProperties": { + "description": "Artifact properties for nested cloud assemblies", + "type": "object", + "properties": { + "directoryName": { + "description": "Relative path to the nested cloud assembly", + "type": "string" + }, + "displayName": { + "description": "Display name for the cloud assembly (Default - The artifact ID)", + "type": "string" + } + }, + "required": [ + "directoryName" + ] + }, "MissingContext": { "description": "Represents a missing piece of context.", "type": "object", diff --git a/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.version.json b/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.version.json index 276fff8f8ba1f..78d33700c0698 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.version.json +++ b/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.version.json @@ -1 +1 @@ -{"version":"4.0.0"} +{"version":"5.0.0"} diff --git a/packages/@aws-cdk/cloud-assembly-schema/scripts/update-schema.sh b/packages/@aws-cdk/cloud-assembly-schema/scripts/update-schema.sh index cde2aafa37aad..424e104e1dc85 100755 --- a/packages/@aws-cdk/cloud-assembly-schema/scripts/update-schema.sh +++ b/packages/@aws-cdk/cloud-assembly-schema/scripts/update-schema.sh @@ -1,7 +1,7 @@ #!/bin/bash set -euo pipefail scriptsdir=$(cd $(dirname $0) && pwd) -packagedir=$(realpath ${scriptsdir}/..) +packagedir=$(cd ${scriptsdir}/.. && pwd) # Output OUTPUT_DIR="${packagedir}/schema" diff --git a/packages/@aws-cdk/core/README.md b/packages/@aws-cdk/core/README.md index 2790600cc3da3..65f9067ff32d4 100644 --- a/packages/@aws-cdk/core/README.md +++ b/packages/@aws-cdk/core/README.md @@ -17,6 +17,42 @@ Guide](https://docs.aws.amazon.com/cdk/latest/guide/home.html) for information of most of the capabilities of this library. The rest of this README will only cover topics not already covered in the Developer Guide. +## Stacks and Stages + +A `Stack` is the smallest physical unit of deployment, and maps directly onto +a CloudFormation Stack. You define a Stack by defining a subclass of `Stack` +-- let's call it `MyStack` -- and instantiating the constructs that make up +your application in `MyStack`'s constructor. You then instantiate this stack +one or more times to define different instances of your application. For example, +you can instantiate it once using few and cheap EC2 instances for testing, +and once again using more and bigger EC2 instances for production. + +When your application grows, you may decide that it makes more sense to split it +out across multiple `Stack` classes. This can happen for a number of reasons: + +- You could be starting to reach the maximum number of resources allowed in a single + stack (this is currently 200). +- You could decide you want to separate out stateful resources and stateless resources + into separate stacks, so that it becomes easy to tear down and recreate the stacks + that don't have stateful resources. +- There could be a single stack with resources (like a VPC) that are shared + between multiple instances of other stacks containing your applications. + +As soon as your conceptual application starts to encompass multiple stacks, +it is convenient to wrap them in another construct that represents your +logical application. You can then treat that new unit the same way you used +to be able to treat a single stack: by instantiating it multiple times +for different instances of your application. + +You can define a custom subclass of `Construct`, holding one or more +`Stack`s, to represent a single logical instance of your application. + +As a final note: `Stack`s are not a unit of reuse. They describe physical +deployment layouts, and as such are best left to application builders to +organize their deployments with. If you want to vend a reusable construct, +define it as a subclasses of `Construct`: the consumers of your construct +will decide where to place it in their own stacks. + ## Nested Stacks [Nested stacks](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-nested-stacks.html) are stacks created as part of other stacks. You create a nested stack within another stack by using the `NestedStack` construct. @@ -36,7 +72,7 @@ class MyNestedStack extends cfn.NestedStack { constructor(scope: Construct, id: string, props?: cfn.NestedStackProps) { super(scope, id, props); - new s3.Bucket(this, 'NestedBucket'); + new s3.Bucket(this, 'NestedBucket'); } } @@ -236,7 +272,7 @@ new CustomResource(this, 'MyMagicalResource', { Property2: 'bar' }, - // the ARN of the provider (SNS/Lambda) which handles + // the ARN of the provider (SNS/Lambda) which handles // CREATE, UPDATE or DELETE events for this resource type // see next section for details serviceToken: 'ARN' @@ -292,7 +328,7 @@ function getOrCreate(scope: Construct): sns.Topic { Every time a resource event occurs (CREATE/UPDATE/DELETE), an SNS notification is sent to the SNS topic. Users must process these notifications (e.g. through a fleet of worker hosts) and submit success/failure responses to the -CloudFormation service. +CloudFormation service. Set `serviceToken` to `topic.topicArn` in order to use this provider: @@ -311,7 +347,7 @@ new CustomResource(this, 'MyResource', { An AWS lambda function is called *directly* by CloudFormation for all resource events. The handler must take care of explicitly submitting a success/failure -response to the CloudFormation service and handle various error cases. +response to the CloudFormation service and handle various error cases. Set `serviceToken` to `lambda.functionArn` to use this provider: @@ -361,7 +397,7 @@ exports.handler = async function(event) { const id = event.PhysicalResourceId; // only for "Update" and "Delete" const props = event.ResourceProperties; const oldProps = event.OldResourceProperties; // only for "Update"s - + switch (event.RequestType) { case "Create": // ... @@ -371,7 +407,7 @@ exports.handler = async function(event) { // if an error is thrown, a FAILED response will be submitted to CFN throw new Error('Failed!'); - + case "Delete": // ... } @@ -403,10 +439,10 @@ Here is an complete example of a custom resource that summarizes two numbers: ```js exports.handler = async e => { - return { - Data: { + return { + Data: { Result: e.ResourceProperties.lhs + e.ResourceProperties.rhs - } + } }; }; ``` @@ -463,7 +499,7 @@ Handlers are implemented as AWS Lambda functions, which means that they can be implemented in any Lambda-supported runtime. Furthermore, this provider has an asynchronous mode, which means that users can provide an `isComplete` lambda function which is called periodically until the operation is complete. This -allows implementing providers that can take up to two hours to stabilize. +allows implementing providers that can take up to two hours to stabilize. Set `serviceToken` to `provider.serviceToken` to use this type of provider: @@ -487,7 +523,7 @@ See the [documentation](https://docs.aws.amazon.com/cdk/api/latest/docs/custom-r Every time a resource event occurs (CREATE/UPDATE/DELETE), an SNS notification is sent to the SNS topic. Users must process these notifications (e.g. through a fleet of worker hosts) and submit success/failure responses to the -CloudFormation service. +CloudFormation service. Set `serviceToken` to `topic.topicArn` in order to use this provider: @@ -506,7 +542,7 @@ new CustomResource(this, 'MyResource', { An AWS lambda function is called *directly* by CloudFormation for all resource events. The handler must take care of explicitly submitting a success/failure -response to the CloudFormation service and handle various error cases. +response to the CloudFormation service and handle various error cases. Set `serviceToken` to `lambda.functionArn` to use this provider: @@ -532,7 +568,7 @@ Handlers are implemented as AWS Lambda functions, which means that they can be implemented in any Lambda-supported runtime. Furthermore, this provider has an asynchronous mode, which means that users can provide an `isComplete` lambda function which is called periodically until the operation is complete. This -allows implementing providers that can take up to two hours to stabilize. +allows implementing providers that can take up to two hours to stabilize. Set `serviceToken` to `provider.serviceToken` to use this provider: @@ -827,7 +863,7 @@ to use intrinsic functions in keys. Since JSON map keys must be strings, it is impossible to use intrinsics in keys and `CfnJson` can help. The following example defines an IAM role which can only be assumed by -principals that are tagged with a specific tag. +principals that are tagged with a specific tag. ```ts const tagParam = new CfnParameter(this, 'TagName'); diff --git a/packages/@aws-cdk/core/lib/app.ts b/packages/@aws-cdk/core/lib/app.ts index 0cc7a1a6ed8d1..1546ab19ee53c 100644 --- a/packages/@aws-cdk/core/lib/app.ts +++ b/packages/@aws-cdk/core/lib/app.ts @@ -1,8 +1,6 @@ import * as cxapi from '@aws-cdk/cx-api'; -import { Construct, ConstructNode } from './construct-compat'; -import { prepareApp } from './private/prepare-app'; -import { collectRuntimeInformation } from './private/runtime-info'; import { TreeMetadata } from './private/tree-metadata'; +import { Stage } from './stage'; const APP_SYMBOL = Symbol.for('@aws-cdk/core.App'); @@ -76,8 +74,7 @@ export interface AppProps { * * @see https://docs.aws.amazon.com/cdk/latest/guide/apps.html */ -export class App extends Construct { - +export class App extends Stage { /** * Checks if an object is an instance of the `App` class. * @returns `true` if `obj` is an `App`. @@ -87,16 +84,14 @@ export class App extends Construct { return APP_SYMBOL in obj; } - private _assembly?: cxapi.CloudAssembly; - private readonly runtimeInfo: boolean; - private readonly outdir?: string; - /** * Initializes a CDK application. * @param props initialization properties */ constructor(props: AppProps = {}) { - super(undefined as any, ''); + super(undefined as any, '', { + outdir: props.outdir ?? process.env[cxapi.OUTDIR_ENV], + }); Object.defineProperty(this, APP_SYMBOL, { value: true }); @@ -110,10 +105,6 @@ export class App extends Construct { this.node.setContext(cxapi.DISABLE_VERSION_REPORTING, true); } - // both are reverse logic - this.runtimeInfo = this.node.tryGetContext(cxapi.DISABLE_VERSION_REPORTING) ? false : true; - this.outdir = props.outdir || process.env[cxapi.OUTDIR_ENV]; - const autoSynth = props.autoSynth !== undefined ? props.autoSynth : cxapi.OUTDIR_ENV in process.env; if (autoSynth) { // synth() guarantuees it will only execute once, so a default of 'true' @@ -126,33 +117,6 @@ export class App extends Construct { } } - /** - * Synthesizes a cloud assembly for this app. Emits it to the directory - * specified by `outdir`. - * - * @returns a `CloudAssembly` which can be used to inspect synthesized - * artifacts such as CloudFormation templates and assets. - */ - public synth(): cxapi.CloudAssembly { - // we already have a cloud assembly, no-op for you - if (this._assembly) { - return this._assembly; - } - - const assembly = ConstructNode.synth(this.node, { - outdir: this.outdir, - runtimeInfo: this.runtimeInfo ? collectRuntimeInformation() : undefined, - }); - - this._assembly = assembly; - return assembly; - } - - protected prepare() { - super.prepare(); - prepareApp(this); - } - private loadContext(defaults: { [key: string]: string } = { }) { // prime with defaults passed through constructor for (const [ k, v ] of Object.entries(defaults)) { diff --git a/packages/@aws-cdk/core/lib/construct-compat.ts b/packages/@aws-cdk/core/lib/construct-compat.ts index 341943a748bca..78e57266fe768 100644 --- a/packages/@aws-cdk/core/lib/construct-compat.ts +++ b/packages/@aws-cdk/core/lib/construct-compat.ts @@ -182,6 +182,8 @@ export enum ConstructOrder { /** * Options for synthesis. + * + * @deprecated use `app.synth()` or `stage.synth()` instead */ export interface SynthesisOptions extends cxapi.AssemblyBuildOptions { /** @@ -222,28 +224,25 @@ export class ConstructNode { /** * Synthesizes a CloudAssembly from a construct tree. - * @param root The root of the construct tree. + * @param node The root of the construct tree. * @param options Synthesis options. + * @deprecated Use `app.synth()` or `stage.synth()` instead */ - public static synth(root: ConstructNode, options: SynthesisOptions = { }): cxapi.CloudAssembly { - const builder = new cxapi.CloudAssemblyBuilder(options.outdir); - - root._actualNode.synthesize({ - outdir: builder.outdir, - skipValidation: options.skipValidation, - sessionContext: { - assembly: builder, - }, - }); - - return builder.buildAssembly(options); + public static synth(node: ConstructNode, options: SynthesisOptions = { }): cxapi.CloudAssembly { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const a: typeof import('././private/synthesis') = require('./private/synthesis'); + return a.synthesize(node.root, options); } /** * Invokes "prepare" on all constructs (depth-first, post-order) in the tree under `node`. * @param node The root node + * @deprecated Use `app.synth()` instead */ public static prepare(node: ConstructNode) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const p: typeof import('./private/prepare-app') = require('./private/prepare-app'); + p.prepareApp(node.root); // resolve cross refs and nested stack assets. return node._actualNode.prepare(); } diff --git a/packages/@aws-cdk/core/lib/deps.ts b/packages/@aws-cdk/core/lib/deps.ts index b26fa28cb187b..c34f11ef2c5a7 100644 --- a/packages/@aws-cdk/core/lib/deps.ts +++ b/packages/@aws-cdk/core/lib/deps.ts @@ -1,5 +1,6 @@ import { CfnResource } from './cfn-resource'; import { Stack } from './stack'; +import { Stage } from './stage'; import { findLastCommonElement, pathToTopLevelStack as pathToRoot } from './util'; type Element = CfnResource | Stack; @@ -31,12 +32,18 @@ export function addDependency(source: T, target: T, reason?: const sourceStack = Stack.of(source); const targetStack = Stack.of(target); + const sourceStage = Stage.of(sourceStack); + const targetStage = Stage.of(targetStack); + if (sourceStage !== targetStage) { + throw new Error(`You cannot add a dependency from '${source.node.path}' (in ${describeStage(sourceStage)}) to '${target.node.path}' (in ${describeStage(targetStage)}): dependency cannot cross stage boundaries`); + } + // find the deepest common stack between the two elements const sourcePath = pathToRoot(sourceStack); const targetPath = pathToRoot(targetStack); const commonStack = findLastCommonElement(sourcePath, targetPath); - // if there is no common stack, then define an assembly-level dependency + // if there is no common stack, then define a assembly-level dependency // between the two top-level stacks if (!commonStack) { const topLevelSource = sourcePath[0]; // first path element is the top-level stack @@ -88,3 +95,12 @@ export function addDependency(source: T, target: T, reason?: return resourceInCommonStackFor(resourceStack); } } + +/** + * Return a string representation of the given assembler, for use in error messages + */ +function describeStage(assembly: Stage | undefined): string { + if (!assembly) { return 'an unrooted construct tree'; } + if (!assembly.parentStage) { return 'the App'; } + return `Stage '${assembly.node.path}'`; +} diff --git a/packages/@aws-cdk/core/lib/index.ts b/packages/@aws-cdk/core/lib/index.ts index 4e55122d5616f..8b238e0c721fd 100644 --- a/packages/@aws-cdk/core/lib/index.ts +++ b/packages/@aws-cdk/core/lib/index.ts @@ -22,6 +22,7 @@ export * from './cfn-resource'; export * from './cfn-resource-policy'; export * from './cfn-rule'; export * from './stack'; +export * from './stage'; export * from './cfn-element'; export * from './cfn-dynamic-reference'; export * from './cfn-tag'; diff --git a/packages/@aws-cdk/core/lib/private/prepare-app.ts b/packages/@aws-cdk/core/lib/private/prepare-app.ts index 6912f29a9fce5..de5ef433fb1ad 100644 --- a/packages/@aws-cdk/core/lib/private/prepare-app.ts +++ b/packages/@aws-cdk/core/lib/private/prepare-app.ts @@ -1,7 +1,8 @@ import { ConstructOrder } from 'constructs'; import { CfnResource } from '../cfn-resource'; -import { Construct, IConstruct } from '../construct-compat'; +import { IConstruct } from '../construct-compat'; import { Stack } from '../stack'; +import { Stage } from '../stage'; import { resolveReferences } from './refs'; /** @@ -14,9 +15,9 @@ import { resolveReferences } from './refs'; * * @param root The root of the construct tree. */ -export function prepareApp(root: Construct) { - if (root.node.scope) { - throw new Error('prepareApp must be called on the root node'); +export function prepareApp(root: IConstruct) { + if (root.node.scope && !Stage.isStage(root)) { + throw new Error('prepareApp can only be called on a stage or a root construct'); } // apply dependencies between resources in depending subtrees @@ -32,7 +33,7 @@ export function prepareApp(root: Construct) { } // depth-first (children first) queue of nested stacks. We will pop a stack - // from the head of this queue to prepare it's template asset. + // from the head of this queue to prepare its template asset. const queue = findAllNestedStacks(root); while (true) { @@ -59,13 +60,23 @@ function defineNestedStackAsset(nestedStack: Stack) { nested._prepareTemplateAsset(); } -function findAllNestedStacks(root: Construct) { +function findAllNestedStacks(root: IConstruct) { const result = new Array(); + const includeStack = (stack: IConstruct): stack is Stack => { + if (!Stack.isStack(stack)) { return false; } + if (!stack.nested) { return false; } + + // test: if we are not within a stage, then include it. + if (!Stage.of(stack)) { return true; } + + return Stage.of(stack) === root; + }; + // create a list of all nested stacks in depth-first post order this means // that we first prepare the leaves and then work our way up. for (const stack of root.node.findAll(ConstructOrder.POSTORDER /* <== important */)) { - if (Stack.isStack(stack) && stack.nested) { + if (includeStack(stack)) { result.push(stack); } } diff --git a/packages/@aws-cdk/core/lib/private/refs.ts b/packages/@aws-cdk/core/lib/private/refs.ts index baa92ff8202e3..62a568f8cd736 100644 --- a/packages/@aws-cdk/core/lib/private/refs.ts +++ b/packages/@aws-cdk/core/lib/private/refs.ts @@ -4,7 +4,7 @@ import { CfnElement } from '../cfn-element'; import { CfnOutput } from '../cfn-output'; import { CfnParameter } from '../cfn-parameter'; -import { Construct } from '../construct-compat'; +import { Construct, IConstruct } from '../construct-compat'; import { Reference } from '../reference'; import { IResolvable } from '../resolvable'; import { Stack } from '../stack'; @@ -18,7 +18,7 @@ import { makeUniqueId } from './uniqueid'; * This is called from the App level to resolve all references defined. Each * reference is resolved based on it's consumption context. */ -export function resolveReferences(scope: Construct): void { +export function resolveReferences(scope: IConstruct): void { const edges = findAllReferences(scope); for (const { source, value } of edges) { @@ -105,7 +105,7 @@ function resolveValue(consumer: Stack, reference: CfnReference): IResolvable { /** * Finds all the CloudFormation references in a construct tree. */ -function findAllReferences(root: Construct) { +function findAllReferences(root: IConstruct) { const result = new Array<{ source: CfnElement, value: CfnReference }>(); for (const consumer of root.node.findAll()) { diff --git a/packages/@aws-cdk/core/lib/private/synthesis.ts b/packages/@aws-cdk/core/lib/private/synthesis.ts new file mode 100644 index 0000000000000..ea6fbf7b05ffa --- /dev/null +++ b/packages/@aws-cdk/core/lib/private/synthesis.ts @@ -0,0 +1,170 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import * as constructs from 'constructs'; +import { Construct, IConstruct, SynthesisOptions, ValidationError } from '../construct-compat'; +import { Stage, StageSynthesisOptions } from '../stage'; +import { prepareApp } from './prepare-app'; + +export function synthesize(root: IConstruct, options: SynthesisOptions = { }): cxapi.CloudAssembly { + // we start by calling "synth" on all nested assemblies (which will take care of all their children) + synthNestedAssemblies(root, options); + + invokeAspects(root); + + // This is mostly here for legacy purposes as the framework itself does not use prepare anymore. + prepareTree(root); + + // resolve references + prepareApp(root); + + // give all children an opportunity to validate now that we've finished prepare + if (!options.skipValidation) { + validateTree(root); + } + + // in unit tests, we support creating free-standing stacks, so we create the + // assembly builder here. + const builder = Stage.isStage(root) + ? root._assemblyBuilder + : new cxapi.CloudAssemblyBuilder(options.outdir); + + // next, we invoke "onSynthesize" on all of our children. this will allow + // stacks to add themselves to the synthesized cloud assembly. + synthesizeTree(root, builder); + + return builder.buildAssembly({ + runtimeInfo: options.runtimeInfo, + }); +} + +/** + * Find Assemblies inside the construct and call 'synth' on them + * + * (They will in turn recurse again) + */ +function synthNestedAssemblies(root: IConstruct, options: StageSynthesisOptions) { + for (const child of root.node.children) { + if (Stage.isStage(child)) { + child.synth(options); + } else { + synthNestedAssemblies(child, options); + } + } +} + +/** + * Invoke aspects on the given construct tree. + * + * Aspects are not propagated across Assembly boundaries. The same Aspect will not be invoked + * twice for the same construct. + */ +function invokeAspects(root: IConstruct) { + recurse(root, []); + + function recurse(construct: IConstruct, inheritedAspects: constructs.IAspect[]) { + // hackery to be able to access some private members with strong types (yack!) + const node: NodeWithAspectPrivatesHangingOut = construct.node._actualNode as any; + + const allAspectsHere = [...inheritedAspects ?? [], ...node._aspects]; + + for (const aspect of allAspectsHere) { + if (node.invokedAspects.includes(aspect)) { continue; } + aspect.visit(construct); + node.invokedAspects.push(aspect); + } + + for (const child of construct.node.children) { + if (!Stage.isStage(child)) { + recurse(child, allAspectsHere); + } + } + } +} + +/** + * Prepare all constructs in the given construct tree in post-order. + * + * Stop at Assembly boundaries. + */ +function prepareTree(root: IConstruct) { + visit(root, 'post', construct => construct.onPrepare()); +} + +/** + * Synthesize children in post-order into the given builder + * + * Stop at Assembly boundaries. + */ +function synthesizeTree(root: IConstruct, builder: cxapi.CloudAssemblyBuilder) { + visit(root, 'post', construct => construct.onSynthesize({ + outdir: builder.outdir, + assembly: builder, + })); +} + +/** + * Validate all constructs in the given construct tree + */ +function validateTree(root: IConstruct) { + const errors = new Array(); + + visit(root, 'pre', construct => { + for (const message of construct.onValidate()) { + errors.push({ message, source: construct as unknown as Construct }); + } + }); + + if (errors.length > 0) { + const errorList = errors.map(e => `[${e.source.node.path}] ${e.message}`).join('\n '); + throw new Error(`Validation failed with the following errors:\n ${errorList}`); + } +} + +/** + * Visit the given construct tree in either pre or post order, stopping at Assemblies + */ +function visit(root: IConstruct, order: 'pre' | 'post', cb: (x: IProtectedConstructMethods) => void) { + if (order === 'pre') { + cb(root as IProtectedConstructMethods); + } + + for (const child of root.node.children) { + if (Stage.isStage(child)) { continue; } + visit(child, order, cb); + } + + if (order === 'post') { + cb(root as IProtectedConstructMethods); + } +} + +/** + * Interface which provides access to special methods of Construct + * + * @experimental + */ +interface IProtectedConstructMethods extends IConstruct { + /** + * Method that gets called when a construct should synthesize itself to an assembly + */ + onSynthesize(session: constructs.ISynthesisSession): void; + + /** + * Method that gets called to validate a construct + */ + onValidate(): string[]; + + /** + * Method that gets called to prepare a construct + */ + onPrepare(): void; +} + +/** + * The constructs Node type, but with some aspects-related fields public. + * + * Hackery! + */ +type NodeWithAspectPrivatesHangingOut = Omit & { + readonly invokedAspects: constructs.IAspect[]; + readonly _aspects: constructs.IAspect[]; +}; \ No newline at end of file diff --git a/packages/@aws-cdk/core/lib/stack.ts b/packages/@aws-cdk/core/lib/stack.ts index 72980dfbfbfe2..eeb65562837ec 100644 --- a/packages/@aws-cdk/core/lib/stack.ts +++ b/packages/@aws-cdk/core/lib/stack.ts @@ -27,8 +27,66 @@ export interface StackProps { /** * The AWS environment (account/region) where this stack will be deployed. * - * @default - The `default-account` and `default-region` context parameters will be - * used. If they are undefined, it will not be possible to deploy the stack. + * Set the `region`/`account` fields of `env` to either a concrete value to + * select the indicated environment (recommended for production stacks), or to + * the values of environment variables + * `CDK_DEFAULT_REGION`/`CDK_DEFAULT_ACCOUNT` to let the target environment + * depend on the AWS credentials/configuration that the CDK CLI is executed + * under (recommended for development stacks). + * + * If the `Stack` is instantiated inside a `Stage`, any undefined + * `region`/`account` fields from `env` will default to the same field on the + * encompassing `Stage`, if configured there. + * + * If either `region` or `account` are not set nor inherited from `Stage`, the + * Stack will be considered "*environment-agnostic*"". Environment-agnostic + * stacks can be deployed to any environment but may not be able to take + * advantage of all features of the CDK. For example, they will not be able to + * use environmental context lookups such as `ec2.Vpc.fromLookup` and will not + * automatically translate Service Principals to the right format based on the + * environment's AWS partition, and other such enhancements. + * + * @example + * + * // Use a concrete account and region to deploy this stack to: + * // `.account` and `.region` will simply return these values. + * new MyStack(app, 'Stack1', { + * env: { + * account: '123456789012', + * region: 'us-east-1' + * }, + * }); + * + * // Use the CLI's current credentials to determine the target environment: + * // `.account` and `.region` will reflect the account+region the CLI + * // is configured to use (based on the user CLI credentials) + * new MyStack(app, 'Stack2', { + * env: { + * account: process.env.CDK_DEFAULT_ACCOUNT, + * region: process.env.CDK_DEFAULT_REGION + * }, + * }); + * + * // Define multiple stacks stage associated with an environment + * const myStage = new Stage(app, 'MyStage', { + * env: { + * account: '123456789012', + * region: 'us-east-1' + * } + * }); + * + * // both of these stavks will use the stage's account/region: + * // `.account` and `.region` will resolve to the concrete values as above + * new MyStack(myStage, 'Stack1'); + * new YourStack(myStage, 'Stack1'); + * + * // Define an environment-agnostic stack: + * // `.account` and `.region` will resolve to `{ "Ref": "AWS::AccountId" }` and `{ "Ref": "AWS::Region" }` respectively. + * // which will only resolve to actual values by CloudFormation during deployment. + * new MyStack(app, 'Stack1'); + * + * @default - The environment of the containing `Stage` if available, + * otherwise create the stack will be environment-agnostic. */ readonly env?: Environment; @@ -265,7 +323,7 @@ export class Stack extends Construct implements ITaggable { this.templateOptions.description = props.description; } - this._stackName = props.stackName !== undefined ? props.stackName : this.generateUniqueId(); + this._stackName = props.stackName !== undefined ? props.stackName : this.generateStackName(); this.tags = new TagManager(TagType.KEY_VALUE, 'aws:cdk:stack', props.tags); if (!VALID_STACK_NAME_REGEX.test(this.stackName)) { @@ -277,8 +335,12 @@ export class Stack extends Construct implements ITaggable { // the same name. however, this behavior is breaking for 1.x so it's only // applied under a feature flag which is applied automatically for new // projects created using `cdk init`. - this.artifactId = this.node.tryGetContext(cxapi.ENABLE_STACK_NAME_DUPLICATES_CONTEXT) - ? this.generateUniqueId() + // + // Also use the new behavior if we are using the new CI/CD-ready synthesizer; that way + // people only have to flip one flag. + // tslint:disable-next-line: max-line-length + this.artifactId = this.node.tryGetContext(cxapi.ENABLE_STACK_NAME_DUPLICATES_CONTEXT) || this.node.tryGetContext(cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT) + ? this.generateStackArtifactId() : this.stackName; this.templateFile = `${this.artifactId}.template.json`; @@ -681,21 +743,6 @@ export class Stack extends Construct implements ITaggable { } } - /** - * Prepare stack - * - * Find all CloudFormation references and tell them we're consuming them. - * - * Find all dependencies as well and add the appropriate DependsOn fields. - */ - protected prepare() { - // if this stack is a roort (e.g. in unit tests), call `prepareApp` so that - // we resolve cross-references and nested stack assets. - if (!this.node.scope) { - prepareApp(this); - } - } - protected synthesize(session: ISynthesisSession): void { // In principle, stack synthesis is delegated to the // StackSynthesis object. @@ -781,12 +828,15 @@ export class Stack extends Construct implements ITaggable { */ private parseEnvironment(env: Environment = {}) { // if an environment property is explicitly specified when the stack is - // created, it will be used. if not, use tokens for account and region but - // they do not need to be scoped, the only situation in which - // export/fn::importvalue would work if { Ref: "AWS::AccountId" } is the - // same for provider and consumer anyway. - const account = env.account || Aws.ACCOUNT_ID; - const region = env.region || Aws.REGION; + // created, it will be used. if not, use tokens for account and region. + // + // (They do not need to be anchored to any construct like resource attributes + // are, because we'll never Export/Fn::ImportValue them -- the only situation + // in which Export/Fn::ImportValue would work is if the value are the same + // between producer and consumer anyway, so we can just assume that they are). + const containingAssembly = Stage.of(this); + const account = env.account ?? containingAssembly?.account ?? Aws.ACCOUNT_ID; + const region = env.region ?? containingAssembly?.region ?? Aws.REGION; // this is the "aws://" env specification that will be written to the cloud assembly // manifest. it will use "unknown-account" and "unknown-region" to indicate @@ -818,24 +868,54 @@ export class Stack extends Construct implements ITaggable { } /** - * Calculcate the stack name based on the construct path + * Calculate the stack name based on the construct path + * + * The stack name is the name under which we'll deploy the stack, + * and incorporates containing Stage names by default. + * + * Generally this looks a lot like how logical IDs are calculated. + * The stack name is calculated based on the construct root path, + * as follows: + * + * - Path is calculated with respect to containing App or Stage (if any) + * - If the path is one component long just use that component, otherwise + * combine them with a hash. + * + * Since the hash is quite ugly and we'd like to avoid it if possible -- but + * we can't anymore in the general case since it has been written into legacy + * stacks. The introduction of Stages makes it possible to make this nicer however. + * When a Stack is nested inside a Stage, we use the path components below the + * Stage, and prefix the path components of the Stage before it. + */ + private generateStackName() { + const assembly = Stage.of(this); + const prefix = (assembly && assembly.stageName) ? `${assembly.stageName}-` : ''; + return `${prefix}${this.generateStackId(assembly)}`; + } + + /** + * The artifact ID for this stack + * + * Stack artifact ID is unique within the App's Cloud Assembly. + */ + private generateStackArtifactId() { + return this.generateStackId(this.node.root); + } + + /** + * Generate an ID with respect to the given container construct. */ - private generateUniqueId() { - // In tests, it's possible for this stack to be the root object, in which case - // we need to use it as part of the root path. - const rootPath = this.node.scope !== undefined ? this.node.scopes.slice(1) : [this]; + private generateStackId(container: IConstruct | undefined) { + const rootPath = rootPathTo(this, container); const ids = rootPath.map(c => c.node.id); - // Special case, if rootPath is length 1 then just use ID (backwards compatibility) - // otherwise use a unique stack name (including hash). This logic is already - // in makeUniqueId, *however* makeUniqueId will also strip dashes from the name, - // which *are* allowed and also used, so we short-circuit it. - if (ids.length === 1) { - // Could be empty in a unit test, so just pretend it's named "Stack" then - return ids[0] || 'Stack'; + // In unit tests our Stack (which is the only component) may not have an + // id, so in that case just pretend it's "Stack". + if (ids.length === 1 && !ids[0]) { + ids[0] = 'Stack'; } - return makeUniqueId(ids); + return makeStackName(ids); } } @@ -950,6 +1030,33 @@ function cfnElements(node: IConstruct, into: CfnElement[] = []): CfnElement[] { return into; } +/** + * Return the construct root path of the given construct relative to the given ancestor + * + * If no ancestor is given or the ancestor is not found, return the entire root path. + */ +export function rootPathTo(construct: IConstruct, ancestor?: IConstruct): IConstruct[] { + const scopes = construct.node.scopes; + for (let i = scopes.length - 2; i >= 0; i--) { + if (scopes[i] === ancestor) { + return scopes.slice(i + 1); + } + } + return scopes; +} + +/** + * makeUniqueId, specialized for Stack names + * + * Stack names may contain '-', so we allow that character if the stack name + * has only one component. Otherwise we fall back to the regular "makeUniqueId" + * behavior. + */ +function makeStackName(components: string[]) { + if (components.length === 1) { return components[0]; } + return makeUniqueId(components); +} + // These imports have to be at the end to prevent circular imports import { Arn, ArnComponents } from './arn'; import { CfnElement } from './cfn-element'; @@ -957,10 +1064,10 @@ import { Fn } from './cfn-fn'; import { Aws, ScopedAws } from './cfn-pseudo'; import { CfnResource, TagType } from './cfn-resource'; import { addDependency } from './deps'; -import { prepareApp } from './private/prepare-app'; import { Reference } from './reference'; import { IResolvable } from './resolvable'; import { DefaultStackSynthesizer, IStackSynthesizer, LegacyStackSynthesizer } from './stack-synthesizers'; +import { Stage } from './stage'; import { ITaggable, TagManager } from './tag-manager'; import { Token } from './token'; diff --git a/packages/@aws-cdk/core/lib/stage.ts b/packages/@aws-cdk/core/lib/stage.ts new file mode 100644 index 0000000000000..59a466499bc9a --- /dev/null +++ b/packages/@aws-cdk/core/lib/stage.ts @@ -0,0 +1,201 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import { Construct, IConstruct } from './construct-compat'; +import { Environment } from './environment'; +import { collectRuntimeInformation } from './private/runtime-info'; +import { synthesize } from './private/synthesis'; + +/** + * Initialization props for a stage. + */ +export interface StageProps { + /** + * Default AWS environment (account/region) for `Stack`s in this `Stage`. + * + * Stacks defined inside this `Stage` with either `region` or `account` missing + * from its env will use the corresponding field given here. + * + * If either `region` or `account`is is not configured for `Stack` (either on + * the `Stack` itself or on the containing `Stage`), the Stack will be + * *environment-agnostic*. + * + * Environment-agnostic stacks can be deployed to any environment, may not be + * able to take advantage of all features of the CDK. For example, they will + * not be able to use environmental context lookups, will not automatically + * translate Service Principals to the right format based on the environment's + * AWS partition, and other such enhancements. + * + * @example + * + * // Use a concrete account and region to deploy this Stage to + * new MyStage(app, 'Stage1', { + * env: { account: '123456789012', region: 'us-east-1' }, + * }); + * + * // Use the CLI's current credentials to determine the target environment + * new MyStage(app, 'Stage2', { + * env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, + * }); + * + * @default - The environments should be configured on the `Stack`s. + */ + readonly env?: Environment; + + /** + * The output directory into which to emit synthesized artifacts. + * + * Can only be specified if this stage is the root stage (the app). If this is + * specified and this stage is nested within another stage, an error will be + * thrown. + * + * @default - for nested stages, outdir will be determined as a relative + * directory to the outdir of the app. For apps, if outdir is not specified, a + * temporary directory will be created. + */ + readonly outdir?: string; +} + +/** + * An abstract application modeling unit consisting of Stacks that should be + * deployed together. + * + * Derive a subclass of `Stage` and use it to model a single instance of your + * application. + * + * You can then instantiate your subclass multiple times to model multiple + * copies of your application which should be be deployed to different + * environments. + */ +export class Stage extends Construct { + /** + * Return the stage this construct is contained with, if available. If called + * on a nested stage, returns its parent. + * + * @experimental + */ + public static of(construct: IConstruct): Stage | undefined { + return construct.node.scopes.reverse().slice(1).find(Stage.isStage); + } + + /** + * Test whether the given construct is a stage. + * + * @experimental + */ + public static isStage(x: any ): x is Stage { + return x !== null && x instanceof Stage; + } + + /** + * The default region for all resources defined within this stage. + * + * @experimental + */ + public readonly region?: string; + + /** + * The default account for all resources defined within this stage. + * + * @experimental + */ + public readonly account?: string; + + /** + * The cloud assembly builder that is being used for this App + * + * @experimental + * @internal + */ + public readonly _assemblyBuilder: cxapi.CloudAssemblyBuilder; + + /** + * The name of the stage. Based on names of the parent stages separated by + * hypens. + * + * @experimental + */ + public readonly stageName: string; + + /** + * The parent stage or `undefined` if this is the app. + * * + * @experimental + */ + public readonly parentStage?: Stage; + + /** + * The cached assembly if it was already built + */ + private assembly?: cxapi.CloudAssembly; + + constructor(scope: Construct, id: string, props: StageProps = {}) { + super(scope, id); + + if (id !== '' && !/^[a-z][a-z0-9\-\_\.]+$/i.test(id)) { + throw new Error(`invalid stage name "${id}". Stage name must start with a letter and contain only alphanumeric characters, hypens ('-'), underscores ('_') and periods ('.')`); + } + + this.parentStage = Stage.of(this); + + this.region = props.env?.region ?? this.parentStage?.region; + this.account = props.env?.account ?? this.parentStage?.account; + + this._assemblyBuilder = this.createBuilder(props.outdir); + this.stageName = [ this.parentStage?.stageName, id ].filter(x => x).join('-'); + } + + /** + * Artifact ID of the assembly if it is a nested stage. The root stage (app) + * will return an empty string. + * + * Derived from the construct path. + * + * @experimental + */ + public get artifactId() { + if (!this.node.path) { return ''; } + return `assembly-${this.node.path.replace(/\//g, '-').replace(/^-+|-+$/g, '')}`; + } + + /** + * Synthesize this stage into a cloud assembly. + * + * Once an assembly has been synthesized, it cannot be modified. Subsequent + * calls will return the same assembly. + */ + public synth(options: StageSynthesisOptions = { }): cxapi.CloudAssembly { + if (!this.assembly) { + const runtimeInfo = this.node.tryGetContext(cxapi.DISABLE_VERSION_REPORTING) ? undefined : collectRuntimeInformation(); + this.assembly = synthesize(this, { + skipValidation: options.skipValidation, + runtimeInfo, + }); + } + + return this.assembly; + } + + private createBuilder(outdir?: string) { + // cannot specify "outdir" if we are a nested stage + if (this.parentStage && outdir) { + throw new Error('"outdir" cannot be specified for nested stages'); + } + + // Need to determine fixed output directory already, because we must know where + // to write sub-assemblies (which must happen before we actually get to this app's + // synthesize() phase). + return this.parentStage + ? this.parentStage._assemblyBuilder.createNestedAssembly(this.artifactId, this.node.path) + : new cxapi.CloudAssemblyBuilder(outdir); + } +} + +/** + * Options for assemly synthesis. + */ +export interface StageSynthesisOptions { + /** + * Should we skip construct validation. + * @default - false + */ + readonly skipValidation?: boolean; +} diff --git a/packages/@aws-cdk/core/test/test.stage.ts b/packages/@aws-cdk/core/test/test.stage.ts new file mode 100644 index 0000000000000..4f5e5f02d0542 --- /dev/null +++ b/packages/@aws-cdk/core/test/test.stage.ts @@ -0,0 +1,304 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import { Test } from 'nodeunit'; +import { App, CfnResource, Construct, IAspect, IConstruct, Stack, Stage } from '../lib'; + +export = { + 'Stack inherits unspecified part of the env from Stage'(test: Test) { + // GIVEN + const app = new App(); + const stage = new Stage(app, 'Stage', { + env: { account: 'account', region: 'region' }, + }); + + // WHEN + const stack1 = new Stack(stage, 'Stack1', { env: { region: 'elsewhere' } }); + const stack2 = new Stack(stage, 'Stack2', { env: { account: 'tnuocca' } }); + + // THEN + test.deepEqual(acctRegion(stack1), ['account', 'elsewhere']); + test.deepEqual(acctRegion(stack2), ['tnuocca', 'region']); + + test.done(); + }, + + 'envs are inherited deeply'(test: Test) { + // GIVEN + const app = new App(); + const outer = new Stage(app, 'Stage', { + env: { account: 'account', region: 'region' }, + }); + + // WHEN + const innerAcct = new Stage(outer, 'Acct', { env: { account: 'tnuocca' }}); + const innerRegion = new Stage(outer, 'Rgn', { env: { region: 'elsewhere' }}); + const innerNeither = new Stage(outer, 'Neither'); + + // THEN + test.deepEqual(acctRegion(new Stack(innerAcct, 'Stack')), ['tnuocca', 'region']); + test.deepEqual(acctRegion(new Stack(innerRegion, 'Stack')), ['account', 'elsewhere']); + test.deepEqual(acctRegion(new Stack(innerNeither, 'Stack')), ['account', 'region']); + + test.done(); + }, + + 'The Stage Assembly is in the app Assembly\'s manifest'(test: Test) { + // WHEN + const app = new App(); + const stage = new Stage(app, 'Stage'); + new BogusStack(stage, 'Stack2'); + + // THEN -- app manifest contains a nested cloud assembly + const appAsm = app.synth(); + + const artifact = appAsm.artifacts.find(x => x instanceof cxapi.NestedCloudAssemblyArtifact); + test.ok(artifact); + + test.done(); + }, + + 'Stacks in Stage are in a different cxasm than Stacks in App'(test: Test) { + // WHEN + const app = new App(); + const stack1 = new BogusStack(app, 'Stack1'); + const stage = new Stage(app, 'Stage'); + const stack2 = new BogusStack(stage, 'Stack2'); + + // THEN + const stageAsm = stage.synth(); + test.deepEqual(stageAsm.stacks.map(s => s.stackName), [stack2.stackName]); + + const appAsm = app.synth(); + test.deepEqual(appAsm.stacks.map(s => s.stackName), [stack1.stackName]); + + test.done(); + }, + + 'Can nest Stages inside other Stages'(test: Test) { + // WHEN + const app = new App(); + const outer = new Stage(app, 'Outer'); + const inner = new Stage(outer, 'Inner'); + const stack = new BogusStack(inner, 'Stack'); + + // WHEN + const appAsm = app.synth(); + const outerAsm = appAsm.getNestedAssembly(outer.artifactId); + const innerAsm = outerAsm.getNestedAssembly(inner.artifactId); + + test.ok(innerAsm.tryGetArtifact(stack.artifactId)); + + test.done(); + }, + + 'Default stack name in Stage objects incorporates the Stage name and no hash'(test: Test) { + // WHEN + const app = new App(); + const stage = new Stage(app, 'MyStage'); + const stack = new BogusStack(stage, 'MyStack'); + + // THEN + test.equal(stage.stageName, 'MyStage'); + test.equal(stack.stackName, 'MyStage-MyStack'); + + test.done(); + }, + + 'Can not have dependencies to stacks outside the nested asm'(test: Test) { + // GIVEN + const app = new App(); + const stack1 = new BogusStack(app, 'Stack1'); + const stage = new Stage(app, 'MyStage'); + const stack2 = new BogusStack(stage, 'Stack2'); + + // WHEN + test.throws(() => { + stack2.addDependency(stack1); + }, /dependency cannot cross stage boundaries/); + + test.done(); + }, + + 'When we synth() a stage, prepare must be called on constructs in the stage'(test: Test) { + // GIVEN + const app = new App(); + let prepared = false; + const stage = new Stage(app, 'MyStage'); + const stack = new BogusStack(stage, 'Stack'); + class HazPrepare extends Construct { + protected prepare() { + prepared = true; + } + } + new HazPrepare(stack, 'Preparable'); + + // WHEN + stage.synth(); + + // THEN + test.equals(prepared, true); + + test.done(); + }, + + 'When we synth() a stage, aspects inside it must have been applied'(test: Test) { + // GIVEN + const app = new App(); + const stage = new Stage(app, 'MyStage'); + const stack = new BogusStack(stage, 'Stack'); + + // WHEN + const aspect = new TouchingAspect(); + stack.node.applyAspect(aspect); + + // THEN + app.synth(); + test.deepEqual(aspect.visits.map(c => c.node.path), [ + 'MyStage/Stack', + 'MyStage/Stack/Resource', + ]); + + test.done(); + }, + + 'Aspects do not apply inside a Stage'(test: Test) { + // GIVEN + const app = new App(); + const stage = new Stage(app, 'MyStage'); + new BogusStack(stage, 'Stack'); + + // WHEN + const aspect = new TouchingAspect(); + app.node.applyAspect(aspect); + + // THEN + app.synth(); + test.deepEqual(aspect.visits.map(c => c.node.path), [ + '', + 'Tree', + ]); + test.done(); + }, + + 'Automatic dependencies inside a stage are available immediately after synth'(test: Test) { + // GIVEN + const app = new App(); + const stage = new Stage(app, 'MyStage'); + const stack1 = new Stack(stage, 'Stack1'); + const stack2 = new Stack(stage, 'Stack2'); + + // WHEN + const resource1 = new CfnResource(stack1, 'Resource', { + type: 'CDK::Test::Resource', + }); + new CfnResource(stack2, 'Resource', { + type: 'CDK::Test::Resource', + properties: { + OtherThing: resource1.ref, + }, + }); + + const asm = stage.synth(); + + // THEN + test.deepEqual( + asm.getStackArtifact(stack2.artifactId).dependencies.map(d => d.id), + [stack1.artifactId]); + + test.done(); + }, + + 'Assemblies can be deeply nested'(test: Test) { + // GIVEN + const app = new App({ runtimeInfo: false, treeMetadata: false }); + + const level1 = new Stage(app, 'StageLevel1'); + const level2 = new Stage(level1, 'StageLevel2'); + new Stage(level2, 'StageLevel3'); + + // WHEN + const rootAssembly = app.synth(); + + // THEN + test.deepEqual(rootAssembly.manifest.artifacts, { + 'assembly-StageLevel1': { + type: 'cdk:cloud-assembly', + properties: { + directoryName: 'assembly-StageLevel1', + displayName: 'StageLevel1', + }, + }, + }); + + const assemblyLevel1 = rootAssembly.getNestedAssembly('assembly-StageLevel1'); + test.deepEqual(assemblyLevel1.manifest.artifacts, { + 'assembly-StageLevel1-StageLevel2': { + type: 'cdk:cloud-assembly', + properties: { + directoryName: 'assembly-StageLevel1-StageLevel2', + displayName: 'StageLevel1/StageLevel2', + }, + }, + }); + + const assemblyLevel2 = assemblyLevel1.getNestedAssembly('assembly-StageLevel1-StageLevel2'); + test.deepEqual(assemblyLevel2.manifest.artifacts, { + 'assembly-StageLevel1-StageLevel2-StageLevel3': { + type: 'cdk:cloud-assembly', + properties: { + directoryName: 'assembly-StageLevel1-StageLevel2-StageLevel3', + displayName: 'StageLevel1/StageLevel2/StageLevel3', + }, + }, + }); + + test.done(); + }, + + 'stage name validation'(test: Test) { + const app = new App(); + + new Stage(app, 'abcd'); + new Stage(app, 'abcd123'); + new Stage(app, 'abcd123-588dfjjk'); + new Stage(app, 'abcd123-588dfjjk.sss'); + new Stage(app, 'abcd123-588dfjjk.sss_ajsid'); + + test.throws(() => new Stage(app, 'abcd123-588dfjjk.sss_ajsid '), /invalid stage name "abcd123-588dfjjk.sss_ajsid "/); + test.throws(() => new Stage(app, 'abcd123-588dfjjk.sss_ajsid/dfo'), /invalid stage name "abcd123-588dfjjk.sss_ajsid\/dfo"/); + test.throws(() => new Stage(app, '&'), /invalid stage name "&"/); + test.throws(() => new Stage(app, '45hello'), /invalid stage name "45hello"/); + test.throws(() => new Stage(app, 'f'), /invalid stage name "f"/); + + test.done(); + }, + + 'outdir cannot be specified for nested stages'(test: Test) { + // WHEN + const app = new App(); + + // THEN + test.throws(() => new Stage(app, 'mystage', { outdir: '/tmp/foo/bar' }), /"outdir" cannot be specified for nested stages/); + test.done(); + }, +}; + +class TouchingAspect implements IAspect { + public readonly visits = new Array(); + public visit(node: IConstruct): void { + this.visits.push(node); + } +} + +class BogusStack extends Stack { + constructor(scope: Construct, id: string) { + super(scope, id); + + new CfnResource(this, 'Resource', { + type: 'CDK::Test::Resource', + }); + } +} + +function acctRegion(s: Stack) { + return [s.account, s.region]; +} \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/design/NESTED_ASSEMBLIES.md b/packages/@aws-cdk/cx-api/design/NESTED_ASSEMBLIES.md new file mode 100644 index 0000000000000..c58caf6ee938b --- /dev/null +++ b/packages/@aws-cdk/cx-api/design/NESTED_ASSEMBLIES.md @@ -0,0 +1,93 @@ +# Nested Assemblies + +For the CI/CD project we need to be able to a final, authoritative, immutable +rendition of part of the construct tree. This is a part of the application +that we can ask the CI/CD system to deploy as a unit, and have it get a fighting +chance of getting it right. This is because: + +- The stacks will be known. +- Their interdependencies will be known, and won't change anymore. + +To that end, we're introducing the concept of an "nested cloud assembly". +This is a part of the construct tree that is finalized independently of the +rest, so that other constructs can reflect on it. + +Constructs of type `Stage` will produce nested cloud assemblies. + +## Restrictions + +### Assets + +Right now, if the same asset is used in multiple cloud assemblies, it will +be staged independently in ever Cloud Assembly (making it take up more +space than necessary). + +This is unfortunate. We can think about sharing the staging directories +between Stages, should be an easy optimization that can be applied later. + +### Dependencies + +It seems that it might be desirable to have dependencies that reach outside +a single `Stage`. Consider the case where we have shared resources that +may be shared between Stages. A typical example would be a VPC: + +``` + ┌───────────────┐ + │ │ + │ VpcStack │ + │ │ + └───────────────┘ + ▲ + │ + │ + ┌─────────────┴─────────────┐ + │ │ +┌───────────────┼──────────┐ ┌──────────┼───────────────┐ +│Stage │ │ │ │ Stage│ +│ │ │ │ │ │ +│ ┌───────────────┐ │ │ ┌───────────────┐ │ +│ │ │ │ │ │ │ │ +│ │ App1Stack │ │ │ │ App2Stack │ │ +│ │ │ │ │ │ │ │ +│ └───────────────┘ │ │ └───────────────┘ │ +│ │ │ │ +└──────────────────────────┘ └──────────────────────────┘ +``` + +This seems like a reasonable thing to want to be able to do. + + +Right now, for practical reasons we're disallowing dependencies outside +nested assemblies. That is not to say that this can never be made to work, +but as it's really rather a significant chunk of work it has not been +implemented yet. Things to consider: + +- Do artifact identifiers need to be globally unique? (Does that destroy + local assumptions around naming that constructs can make?) +- How are artifacts addressed across assembly boundaries? Are they just the + absolute name, wherever in the Cloud Assembly tree the artifact is? Do they + represent a path from the top-level cloud assembly + (`SubAsm/SubAsm/Artifact`)? Are they relative paths (`../SubAsm/Artifact`)? +- Can there be cyclic dependencies between nested assemblies? Is it okay to + have both dependencies `AsmA/Stack1 -> AsmB/Stack1`, and `AsmB/Stack2 -> + AsmA/Stack2`? Why, or why not? How will we ensure that? + +Even if we can make the addressing work at the artifact level, at the +construct tree level we'd be giving up the guarantees we are getting from +having `Stage` constructs produce isolated Cloud Assemblies by having +dependencies outside them. Consider having two stages, `StageA` with `StackA` +and `StageB` with `StackB`. We must `synth()` them in some order, either A or +B first. Let's say A goes first (but the same argument obviously holds in +reverse). What if during the `synth()` of `StageB`, we discover `StackB` +introduces a dependency on `StackA`? By that point, `StageA` has already +synthesized and `StackA` has produced a (so-called "immutable") template. +Obviously we can't change that anymore, so we can't introduce that dependency +anymore. + +Seems like we should be calling `synth()` on multiple stages consumer-first! + +The problem is that we are generally building a Pipeline *producer*-first, since +we are modeling and building it in deployment order, which is the reverse order +the pipeline would `synth()` each of the stages in, in order to build itself. + +Since this is all very tricky, let's consider it out of scope for now. \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/jest.config.js b/packages/@aws-cdk/cx-api/jest.config.js index cd664e1d069e5..d984ff822379b 100644 --- a/packages/@aws-cdk/cx-api/jest.config.js +++ b/packages/@aws-cdk/cx-api/jest.config.js @@ -1,2 +1,10 @@ const baseConfig = require('../../../tools/cdk-build-tools/config/jest.config'); -module.exports = baseConfig; +module.exports = { + ...baseConfig, + coverageThreshold: { + global: { + ...baseConfig.coverageThreshold.global, + branches: 75, + }, + }, +}; diff --git a/packages/@aws-cdk/cx-api/lib/asset-manifest-artifact.ts b/packages/@aws-cdk/cx-api/lib/artifacts/asset-manifest-artifact.ts similarity index 90% rename from packages/@aws-cdk/cx-api/lib/asset-manifest-artifact.ts rename to packages/@aws-cdk/cx-api/lib/artifacts/asset-manifest-artifact.ts index 8146a276d7e4f..07414bafe4249 100644 --- a/packages/@aws-cdk/cx-api/lib/asset-manifest-artifact.ts +++ b/packages/@aws-cdk/cx-api/lib/artifacts/asset-manifest-artifact.ts @@ -1,7 +1,7 @@ import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import * as path from 'path'; -import { CloudArtifact } from './cloud-artifact'; -import { CloudAssembly } from './cloud-assembly'; +import { CloudArtifact } from '../cloud-artifact'; +import { CloudAssembly } from '../cloud-assembly'; /** * Asset manifest is a description of a set of assets which need to be built and published diff --git a/packages/@aws-cdk/cx-api/lib/cloudformation-artifact.ts b/packages/@aws-cdk/cx-api/lib/artifacts/cloudformation-artifact.ts similarity index 89% rename from packages/@aws-cdk/cx-api/lib/cloudformation-artifact.ts rename to packages/@aws-cdk/cx-api/lib/artifacts/cloudformation-artifact.ts index 2373e45e0eabc..e22bc5764a798 100644 --- a/packages/@aws-cdk/cx-api/lib/cloudformation-artifact.ts +++ b/packages/@aws-cdk/cx-api/lib/artifacts/cloudformation-artifact.ts @@ -1,16 +1,11 @@ import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import * as fs from 'fs'; import * as path from 'path'; -import { CloudArtifact } from './cloud-artifact'; -import { CloudAssembly } from './cloud-assembly'; -import { Environment, EnvironmentUtils } from './environment'; +import { CloudArtifact } from '../cloud-artifact'; +import { CloudAssembly } from '../cloud-assembly'; +import { Environment, EnvironmentUtils } from '../environment'; export class CloudFormationStackArtifact extends CloudArtifact { - /** - * The CloudFormation template for this stack. - */ - public readonly template: any; - /** * The file name of the template. */ @@ -87,6 +82,8 @@ export class CloudFormationStackArtifact extends CloudArtifact { */ public readonly terminationProtection?: boolean; + private _template: any | undefined; + constructor(assembly: CloudAssembly, artifactId: string, artifact: cxschema.ArtifactManifest) { super(assembly, artifactId, artifact); @@ -107,7 +104,6 @@ export class CloudFormationStackArtifact extends CloudArtifact { this.terminationProtection = properties.terminationProtection; this.stackName = properties.stackName || artifactId; - this.template = JSON.parse(fs.readFileSync(path.join(this.assembly.directory, this.templateFile), 'utf-8')); this.assets = this.findMetadataByType(cxschema.ArtifactMetadataEntryType.ASSET).map(e => e.data as cxschema.AssetMetadataEntry); this.displayName = this.stackName === artifactId @@ -117,4 +113,14 @@ export class CloudFormationStackArtifact extends CloudArtifact { this.name = this.stackName; // backwards compat this.originalName = this.stackName; } + + /** + * The CloudFormation template for this stack. + */ + public get template(): any { + if (this._template === undefined) { + this._template = JSON.parse(fs.readFileSync(path.join(this.assembly.directory, this.templateFile), 'utf-8')); + } + return this._template; + } } diff --git a/packages/@aws-cdk/cx-api/lib/artifacts/nested-cloud-assembly-artifact.ts b/packages/@aws-cdk/cx-api/lib/artifacts/nested-cloud-assembly-artifact.ts new file mode 100644 index 0000000000000..bf3e378774d96 --- /dev/null +++ b/packages/@aws-cdk/cx-api/lib/artifacts/nested-cloud-assembly-artifact.ts @@ -0,0 +1,49 @@ +import * as cxschema from '@aws-cdk/cloud-assembly-schema'; +import * as path from 'path'; +import { CloudArtifact } from '../cloud-artifact'; +import { CloudAssembly } from '../cloud-assembly'; + +/** + * Asset manifest is a description of a set of assets which need to be built and published + */ +export class NestedCloudAssemblyArtifact extends CloudArtifact { + /** + * The relative directory name of the asset manifest + */ + public readonly directoryName: string; + + /** + * Display name + */ + public readonly displayName: string; + + /** + * Cache for the inner assembly loading + */ + private _nestedAssembly?: CloudAssembly; + + constructor(assembly: CloudAssembly, name: string, artifact: cxschema.ArtifactManifest) { + super(assembly, name, artifact); + + const properties = (this.manifest.properties || {}) as cxschema.NestedCloudAssemblyProperties; + this.directoryName = properties.directoryName; + this.displayName = properties.displayName ?? name; + } + + /** + * Full path to the nested assembly directory + */ + public get fullPath(): string { + return path.join(this.assembly.directory, this.directoryName); + } + + /** + * The nested Assembly + */ + public get nestedAssembly(): CloudAssembly { + if (!this._nestedAssembly) { + this._nestedAssembly = new CloudAssembly(this.fullPath); + } + return this._nestedAssembly; + } +} diff --git a/packages/@aws-cdk/cx-api/lib/tree-cloud-artifact.ts b/packages/@aws-cdk/cx-api/lib/artifacts/tree-cloud-artifact.ts similarity index 83% rename from packages/@aws-cdk/cx-api/lib/tree-cloud-artifact.ts rename to packages/@aws-cdk/cx-api/lib/artifacts/tree-cloud-artifact.ts index 142671e882e23..689f3468ca252 100644 --- a/packages/@aws-cdk/cx-api/lib/tree-cloud-artifact.ts +++ b/packages/@aws-cdk/cx-api/lib/artifacts/tree-cloud-artifact.ts @@ -1,6 +1,6 @@ import * as cxschema from '@aws-cdk/cloud-assembly-schema'; -import { CloudArtifact } from './cloud-artifact'; -import { CloudAssembly } from './cloud-assembly'; +import { CloudArtifact } from '../cloud-artifact'; +import { CloudAssembly } from '../cloud-assembly'; export class TreeCloudArtifact extends CloudArtifact { public readonly file: string; diff --git a/packages/@aws-cdk/cx-api/lib/cloud-artifact.ts b/packages/@aws-cdk/cx-api/lib/cloud-artifact.ts index 55cd7567e1612..9abfdb8d660d5 100644 --- a/packages/@aws-cdk/cx-api/lib/cloud-artifact.ts +++ b/packages/@aws-cdk/cx-api/lib/cloud-artifact.ts @@ -49,6 +49,8 @@ export class CloudArtifact { return new TreeCloudArtifact(assembly, id, artifact); case cxschema.ArtifactType.ASSET_MANIFEST: return new AssetManifestArtifact(assembly, id, artifact); + case cxschema.ArtifactType.NESTED_CLOUD_ASSEMBLY: + return new NestedCloudAssemblyArtifact(assembly, id, artifact); default: return undefined; } @@ -88,7 +90,7 @@ export class CloudArtifact { if (this._deps) { return this._deps; } this._deps = this._dependencyIDs.map(id => { - const dep = this.assembly.artifacts.find(a => a.id === id); + const dep = this.assembly.tryGetArtifact(id); if (!dep) { throw new Error(`Artifact ${this.id} depends on non-existing artifact ${id}`); } @@ -143,6 +145,7 @@ export class CloudArtifact { } // needs to be defined at the end to avoid a cyclic dependency -import { AssetManifestArtifact } from './asset-manifest-artifact'; -import { CloudFormationStackArtifact } from './cloudformation-artifact'; -import { TreeCloudArtifact } from './tree-cloud-artifact'; \ No newline at end of file +import { AssetManifestArtifact } from './artifacts/asset-manifest-artifact'; +import { CloudFormationStackArtifact } from './artifacts/cloudformation-artifact'; +import { NestedCloudAssemblyArtifact } from './artifacts/nested-cloud-assembly-artifact'; +import { TreeCloudArtifact } from './artifacts/tree-cloud-artifact'; \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts b/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts index 0cf2e3d2ea9e0..b12c8a52ccdb6 100644 --- a/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts +++ b/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts @@ -2,10 +2,11 @@ import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { CloudFormationStackArtifact } from './artifacts/cloudformation-artifact'; +import { NestedCloudAssemblyArtifact } from './artifacts/nested-cloud-assembly-artifact'; +import { TreeCloudArtifact } from './artifacts/tree-cloud-artifact'; import { CloudArtifact } from './cloud-artifact'; -import { CloudFormationStackArtifact } from './cloudformation-artifact'; import { topologicalSort } from './toposort'; -import { TreeCloudArtifact } from './tree-cloud-artifact'; /** * The name of the root manifest file of the assembly. @@ -69,6 +70,8 @@ export class CloudAssembly { /** * Returns a CloudFormation stack artifact from this assembly. * + * Will only search the current assembly. + * * @param stackName the name of the CloudFormation stack. * @throws if there is no stack artifact by that name * @throws if there is more than one stack with the same stack name. You can @@ -116,6 +119,33 @@ export class CloudAssembly { return artifact; } + /** + * Returns a nested assembly artifact. + * + * @param artifactId The artifact ID of the nested assembly + */ + public getNestedAssemblyArtifact(artifactId: string): NestedCloudAssemblyArtifact { + const artifact = this.tryGetArtifact(artifactId); + if (!artifact) { + throw new Error(`Unable to find artifact with id "${artifactId}"`); + } + + if (!(artifact instanceof NestedCloudAssemblyArtifact)) { + throw new Error(`Found artifact '${artifactId}' but it's not a nested cloud assembly`); + } + + return artifact; + } + + /** + * Returns a nested assembly. + * + * @param artifactId The artifact ID of the nested assembly + */ + public getNestedAssembly(artifactId: string): CloudAssembly { + return this.getNestedAssemblyArtifact(artifactId).nestedAssembly; + } + /** * Returns the tree metadata artifact from this assembly. * @throws if there is no metadata artifact by that name @@ -186,7 +216,7 @@ export class CloudAssemblyBuilder { * @param outdir The output directory, uses temporary directory if undefined */ constructor(outdir?: string) { - this.outdir = outdir || fs.mkdtempSync(path.join(os.tmpdir(), 'cdk.out')); + this.outdir = determineOutputDirectory(outdir); // we leverage the fact that outdir is long-lived to avoid staging assets into it // that were already staged (copying can be expensive). this is achieved by the fact @@ -198,7 +228,7 @@ export class CloudAssemblyBuilder { throw new Error(`${this.outdir} must be a directory`); } } else { - fs.mkdirSync(this.outdir); + fs.mkdirSync(this.outdir, { recursive: true }); } } @@ -251,6 +281,23 @@ export class CloudAssemblyBuilder { return new CloudAssembly(this.outdir); } + /** + * Creates a nested cloud assembly + */ + public createNestedAssembly(artifactId: string, displayName: string) { + const directoryName = artifactId; + const innerAsmDir = path.join(this.outdir, directoryName); + + this.addArtifact(artifactId, { + type: cxschema.ArtifactType.NESTED_CLOUD_ASSEMBLY, + properties: { + directoryName, + displayName, + } as cxschema.NestedCloudAssemblyProperties, + }); + + return new CloudAssemblyBuilder(innerAsmDir); + } } /** @@ -341,3 +388,10 @@ function filterUndefined(obj: any): any { function ignore(_x: any) { return; } + +/** + * Turn the given optional output directory into a fixed output directory + */ +function determineOutputDirectory(outdir?: string) { + return outdir ?? fs.mkdtempSync(path.join(os.tmpdir(), 'cdk.out')); +} diff --git a/packages/@aws-cdk/cx-api/lib/index.ts b/packages/@aws-cdk/cx-api/lib/index.ts index 916ee80b068d4..a6ac4977a6d17 100644 --- a/packages/@aws-cdk/cx-api/lib/index.ts +++ b/packages/@aws-cdk/cx-api/lib/index.ts @@ -4,9 +4,10 @@ export * from './context/ami'; export * from './context/availability-zones'; export * from './context/endpoint-service-availability-zones'; export * from './cloud-artifact'; -export * from './asset-manifest-artifact'; -export * from './cloudformation-artifact'; -export * from './tree-cloud-artifact'; +export * from './artifacts/asset-manifest-artifact'; +export * from './artifacts/cloudformation-artifact'; +export * from './artifacts/tree-cloud-artifact'; +export * from './artifacts/nested-cloud-assembly-artifact'; export * from './cloud-assembly'; export * from './assets'; export * from './environment'; diff --git a/packages/@aws-cdk/cx-api/test/cloud-assembly-builder.test.ts b/packages/@aws-cdk/cx-api/test/cloud-assembly-builder.test.ts index bc348d9442188..1512c86ff5044 100644 --- a/packages/@aws-cdk/cx-api/test/cloud-assembly-builder.test.ts +++ b/packages/@aws-cdk/cx-api/test/cloud-assembly-builder.test.ts @@ -2,12 +2,12 @@ import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { CloudAssemblyBuilder } from '../lib'; +import * as cxapi from '../lib'; test('cloud assembly builder', () => { // GIVEN const outdir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloud-assembly-builder-tests')); - const session = new CloudAssemblyBuilder(outdir); + const session = new cxapi.CloudAssemblyBuilder(outdir); const templateFile = 'foo.template.json'; // WHEN @@ -121,12 +121,12 @@ test('cloud assembly builder', () => { }); test('outdir must be a directory', () => { - expect(() => new CloudAssemblyBuilder(__filename)).toThrow('must be a directory'); + expect(() => new cxapi.CloudAssemblyBuilder(__filename)).toThrow('must be a directory'); }); test('duplicate missing values with the same key are only reported once', () => { const outdir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloud-assembly-builder-tests')); - const session = new CloudAssemblyBuilder(outdir); + const session = new cxapi.CloudAssemblyBuilder(outdir); const props: cxschema.ContextQueryProperties = { account: '1234', @@ -141,3 +141,29 @@ test('duplicate missing values with the same key are only reported once', () => expect(assembly.manifest.missing!.length).toEqual(1); }); + +test('write and read nested cloud assembly artifact', () => { + // GIVEN + const outdir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloud-assembly-builder-tests')); + const session = new cxapi.CloudAssemblyBuilder(outdir); + + const innerAsmDir = path.join(outdir, 'hello'); + new cxapi.CloudAssemblyBuilder(innerAsmDir).buildAssembly(); + + // WHEN + session.addArtifact('Assembly', { + type: cxschema.ArtifactType.NESTED_CLOUD_ASSEMBLY, + properties: { + directoryName: 'hello', + } as cxschema.NestedCloudAssemblyProperties, + }); + const asm = session.buildAssembly(); + + // THEN + const art = asm.tryGetArtifact('Assembly') as cxapi.NestedCloudAssemblyArtifact | undefined; + expect(art).toBeInstanceOf(cxapi.NestedCloudAssemblyArtifact); + expect(art?.fullPath).toEqual(path.join(outdir, 'hello')); + + const nested = art?.nestedAssembly; + expect(nested?.artifacts.length).toEqual(0); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/test/placeholders.test.ts b/packages/@aws-cdk/cx-api/test/placeholders.test.ts index 9b39478abd611..658d8a4670433 100644 --- a/packages/@aws-cdk/cx-api/test/placeholders.test.ts +++ b/packages/@aws-cdk/cx-api/test/placeholders.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentPlaceholders, IEnvironmentPlaceholderProvider } from '../lib'; +import { EnvironmentPlaceholders, EnvironmentPlaceholderValues, IEnvironmentPlaceholderProvider } from '../lib'; test('complex placeholder substitution', async () => { const replacer: IEnvironmentPlaceholderProvider = { @@ -25,3 +25,30 @@ test('complex placeholder substitution', async () => { }, }); }); + +test('sync placeholder substitution', () => { + const replacer: EnvironmentPlaceholderValues = { + accountId: 'current_account', + region: 'current_region', + partition: 'current_partition', + }; + + expect(EnvironmentPlaceholders.replace({ + destinations: { + theDestination: { + assumeRoleArn: 'arn:${AWS::Partition}:role-${AWS::AccountId}', + bucketName: 'some_bucket-${AWS::AccountId}-${AWS::Region}', + objectKey: 'some_key-${AWS::AccountId}-${AWS::Region}', + }, + }, + }, replacer)).toEqual({ + destinations: { + theDestination: { + assumeRoleArn: 'arn:current_partition:role-current_account', + bucketName: 'some_bucket-current_account-current_region', + objectKey: 'some_key-current_account-current_region', + }, + }, + }); + +});