From b81502e710bb72ab3291332e7c4720183554d10f Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Tue, 4 Jun 2019 02:13:36 +0300 Subject: [PATCH] refactor(core): Misc cleanups to App-related APIs Ensure documentation coverage and clean up of the various APIs. Fixes #1891 BREAKING CHANGE: * **core:** `StackProps.autoDeploy` has been removed and replaced by `StackProps.hide` (with negated logic). * **core:** `ISynthesizable.synthesize` now accepts an `ISynthesisSession` which contains the `CloudAssemblyBuilder` object. * **cx-api:** Multiple changes to the cloud assembly APIs to reduce surface area and clean up. --- packages/@aws-cdk/assert/lib/inspector.ts | 5 +- packages/@aws-cdk/assets/lib/staging.ts | 9 +- packages/@aws-cdk/assets/test/test.asset.ts | 5 +- packages/@aws-cdk/cdk/lib/app.ts | 48 ++++-- packages/@aws-cdk/cdk/lib/cfn-element.ts | 7 +- packages/@aws-cdk/cdk/lib/stack.ts | 41 ++--- packages/@aws-cdk/cdk/lib/synthesis.ts | 15 +- packages/@aws-cdk/cdk/test/test.app.ts | 6 +- packages/@aws-cdk/cdk/test/test.context.ts | 2 +- packages/@aws-cdk/cdk/test/test.synthesis.ts | 23 ++- .../@aws-cdk/cx-api/lib/cloud-artifact.ts | 147 ++++++++++++++---- .../@aws-cdk/cx-api/lib/cloud-assembly.ts | 100 +++++++++--- .../cx-api/lib/cloudformation-artifact.ts | 71 ++++----- packages/@aws-cdk/cx-api/lib/metadata.ts | 12 ++ .../cx-api/test/cloud-assembly.test.ts | 22 +-- packages/aws-cdk/bin/cdk.ts | 2 +- packages/aws-cdk/lib/api/cxapp/stacks.ts | 30 ++-- .../cloudformation/stack-activity-monitor.ts | 11 +- packages/aws-cdk/lib/diff.ts | 12 +- packages/aws-cdk/test/api/test.stacks.ts | 10 +- packages/aws-cdk/test/util.ts | 4 +- 21 files changed, 388 insertions(+), 194 deletions(-) diff --git a/packages/@aws-cdk/assert/lib/inspector.ts b/packages/@aws-cdk/assert/lib/inspector.ts index ee446ab95b28a..e450c2e20ee39 100644 --- a/packages/@aws-cdk/assert/lib/inspector.ts +++ b/packages/@aws-cdk/assert/lib/inspector.ts @@ -54,9 +54,10 @@ export class StackPathInspector extends Inspector { // The names of paths in metadata in tests are very ill-defined. Try with the full path first, // then try with the stack name preprended for backwards compat with most tests that happen to give // their stack an ID that's the same as the stack name. - const md = this.stack.metadata[this.path] || this.stack.metadata[`/${this.stack.name}${this.path}`]; + const metadata = this.stack.manifest.metadata || {}; + const md = metadata[this.path] || metadata[`/${this.stack.name}${this.path}`]; if (md === undefined) { return undefined; } - const resourceMd = md.find(entry => entry.type === 'aws:cdk:logicalId'); + const resourceMd = md.find(entry => entry.type === api.LOGICAL_ID_METADATA_KEY); if (resourceMd === undefined) { return undefined; } const logicalId = resourceMd.data; return this.stack.template.Resources[logicalId]; diff --git a/packages/@aws-cdk/assets/lib/staging.ts b/packages/@aws-cdk/assets/lib/staging.ts index 173fe73374fa7..f630c1e0772b6 100644 --- a/packages/@aws-cdk/assets/lib/staging.ts +++ b/packages/@aws-cdk/assets/lib/staging.ts @@ -1,4 +1,4 @@ -import { Construct } from '@aws-cdk/cdk'; +import { Construct, ISynthesisSession, ISynthesizable } from '@aws-cdk/cdk'; import cxapi = require('@aws-cdk/cx-api'); import fs = require('fs'); import path = require('path'); @@ -26,7 +26,7 @@ export interface StagingProps extends CopyOptions { * The file/directory are staged based on their content hash (fingerprint). This * means that only if content was changed, copy will happen. */ -export class Staging extends Construct { +export class Staging extends Construct implements ISynthesizable { /** * The path to the asset (stringinfied token). @@ -66,12 +66,13 @@ export class Staging extends Construct { } } - protected synthesize(session: cxapi.CloudAssemblyBuilder) { + public synthesize(session: ISynthesisSession) { + const assembly = session.assembly; if (!this.relativePath) { return; } - const targetPath = path.join(session.outdir, this.relativePath); + const targetPath = path.join(assembly.outdir, this.relativePath); // asset already staged if (fs.existsSync(targetPath)) { diff --git a/packages/@aws-cdk/assets/test/test.asset.ts b/packages/@aws-cdk/assets/test/test.asset.ts index 53a3a42020d5c..d752c56b5a735 100644 --- a/packages/@aws-cdk/assets/test/test.asset.ts +++ b/packages/@aws-cdk/assets/test/test.asset.ts @@ -58,7 +58,8 @@ export = { }); const synth = app.run().getStack(stack.name); - test.deepEqual(synth.metadata['/my-stack/MyAsset'][0].data, { + const meta = synth.manifest.metadata || {}; + test.deepEqual(meta['/my-stack/MyAsset'][0].data, { path: 'asset.6b84b87243a4a01c592d78e1fd3855c4bfef39328cd0a450cc97e81717fea2a2', id: "mystackMyAssetD6B1B593", packaging: "zip", @@ -343,7 +344,7 @@ export = { const session = app.run(); const artifact = session.getStack(stack.name); - const md = Object.values(artifact.metadata)[0][0].data; + const md = Object.values(artifact.manifest.metadata || {})[0][0].data; test.deepEqual(md.path, 'asset.6b84b87243a4a01c592d78e1fd3855c4bfef39328cd0a450cc97e81717fea2a2'); test.done(); } diff --git a/packages/@aws-cdk/cdk/lib/app.ts b/packages/@aws-cdk/cdk/lib/app.ts index 79d021493f310..42a001d585ce1 100644 --- a/packages/@aws-cdk/cdk/lib/app.ts +++ b/packages/@aws-cdk/cdk/lib/app.ts @@ -7,7 +7,7 @@ import { Synthesizer } from './synthesis'; const APP_SYMBOL = Symbol.for('@aws-cdk/cdk.App'); /** - * Custom construction properties for a CDK program + * Initialization props for apps. */ export interface AppProps { /** @@ -15,7 +15,7 @@ export interface AppProps { * * If you set this, you don't have to call `run()` anymore. * - * @default true if running via CDK toolkit (CDK_OUTDIR is set), false otherwise + * @default true if running via CDK toolkit (`CDK_OUTDIR` is set), false otherwise */ readonly autoRun?: boolean; @@ -29,29 +29,48 @@ export interface AppProps { /** * Include stack traces in construct metadata entries. - * @default true stack traces are included + * @default true stack traces are included unless `aws:cdk:disable-stack-trace` is set in the context. */ readonly stackTraces?: boolean; /** * Include runtime versioning information in cloud assembly manifest - * @default true runtime info is included + * @default true runtime info is included unless `aws:cdk:disable-runtime-info` is set in the context. */ readonly runtimeInfo?: boolean; /** - * Additional context values for the application + * Additional context values for the application. * - * @default No additional context + * Context can be read from any construct using `node.getContext(key)`. + * + * @default - no additional context */ readonly context?: { [key: string]: string }; } /** - * Represents a CDK program. + * A construct which represents an entire CDK app. This construct is normally + * the root of the construct tree. + * + * You would normally define an `App` instance in your program's entrypoint, + * then define constructs where the app is used as the parent scope. + * + * After all the child constructs are defined within the app, you should call + * `app.synth()` which will emit a "cloud assembly" from this app into the + * directory specified by `outdir`. Cloud assemblies includes artifacts such as + * CloudFormation templates and assets that are needed to deploy this app into + * the AWS cloud. + * + * @see https://docs.aws.amazon.com/cdk/latest/guide/apps_and_stacks.html */ export class App extends Construct { + /** + * Checks if an object is an instance of the `App` class. + * @returns `true` if `obj` is an `App`. + * @param obj The object to evaluate + */ public static isApp(obj: any): obj is App { return APP_SYMBOL in obj; } @@ -62,7 +81,7 @@ export class App extends Construct { /** * Initializes a CDK application. - * @param request Optional toolkit request (e.g. for tests) + * @param props initialization properties */ constructor(props: AppProps = {}) { super(undefined as any, ''); @@ -84,20 +103,19 @@ export class App extends Construct { this.outdir = props.outdir || process.env[cxapi.OUTDIR_ENV]; const autoRun = props.autoRun !== undefined ? props.autoRun : cxapi.OUTDIR_ENV in process.env; - if (autoRun) { - // run() guarantuees it will only execute once, so a default of 'true' doesn't bite manual calling - // of the function. + // run() guarantuees it will only execute once, so a default of 'true' + // doesn't bite manual calling of the function. process.once('beforeExit', () => this.run()); } } /** - * Runs the program. Output is written to output directory as specified in the - * request. + * Synthesizes a cloud assembly for this app. Emits it to the directory + * specified by `outdir`. * - * @returns a `CloudAssembly` which includes all the synthesized artifacts - * such as CloudFormation templates and assets. + * @returns a `CloudAssembly` which can be used to inspect synthesized + * artifacts such as CloudFormation templates and assets. */ public run(): CloudAssembly { // this app has already been executed, no-op for you diff --git a/packages/@aws-cdk/cdk/lib/cfn-element.ts b/packages/@aws-cdk/cdk/lib/cfn-element.ts index c5bd71e24b2fd..468a78441a55c 100644 --- a/packages/@aws-cdk/cdk/lib/cfn-element.ts +++ b/packages/@aws-cdk/cdk/lib/cfn-element.ts @@ -1,8 +1,7 @@ +import cxapi = require('@aws-cdk/cx-api'); import { Construct, IConstruct, PATH_SEP } from "./construct"; import { Token } from './token'; -const LOGICAL_ID_MD = 'aws:cdk:logicalId'; - /** * An element of a CloudFormation stack. */ @@ -43,7 +42,7 @@ export abstract class CfnElement extends Construct { constructor(scope: Construct, id: string) { super(scope, id); - this.node.addMetadata(LOGICAL_ID_MD, new (require("./token").Token)(() => this.logicalId), this.constructor); + this.node.addMetadata(cxapi.LOGICAL_ID_METADATA_KEY, new (require("./token").Token)(() => this.logicalId), this.constructor); this._logicalId = this.node.stack.logicalIds.getLogicalId(this); this.logicalId = new Token(() => this._logicalId, `${notTooLong(this.node.path)}.LogicalID`).toString(); @@ -63,7 +62,7 @@ export abstract class CfnElement extends Construct { * node +internal+ entries filtered. */ public get creationStackTrace(): string[] | undefined { - const trace = this.node.metadata.find(md => md.type === LOGICAL_ID_MD)!.trace; + const trace = this.node.metadata.find(md => md.type === cxapi.LOGICAL_ID_METADATA_KEY)!.trace; if (!trace) { return undefined; } diff --git a/packages/@aws-cdk/cdk/lib/stack.ts b/packages/@aws-cdk/cdk/lib/stack.ts index 41d3626c57fe0..07bbb61e009db 100644 --- a/packages/@aws-cdk/cdk/lib/stack.ts +++ b/packages/@aws-cdk/cdk/lib/stack.ts @@ -6,7 +6,9 @@ import { CfnParameter } from './cfn-parameter'; import { Construct, IConstruct, PATH_SEP } from './construct'; import { Environment } from './environment'; import { HashedAddressingScheme, IAddressingScheme, LogicalIDs } from './logical-id'; +import { ISynthesisSession } from './synthesis'; import { makeUniqueId } from './uniqueid'; + export interface StackProps { /** * The AWS environment (account/region) where this stack will be deployed. @@ -31,15 +33,16 @@ export interface StackProps { readonly namingScheme?: IAddressingScheme; /** - * Should the Stack be deployed when running `cdk deploy` without arguments - * (and listed when running `cdk synth` without arguments). - * Setting this to `false` is useful when you have a Stack in your CDK app - * that you don't want to deploy using the CDK toolkit - - * for example, because you're planning on deploying it through CodePipeline. + * Indicates if this stack is hidden, which means that it won't be + * automatically deployed when running `cdk deploy` without arguments. + * + * Setting this to `true` is useful when you have a stack in your CDK app + * which you don't want to deploy using the CDK toolkit. For example, because + * you're planning on deploying it through a deployment pipeline. * - * @default true + * @default false */ - readonly autoDeploy?: boolean; + readonly hidden?: boolean; /** * Stack tags that will be applied to all the taggable resources and the stack itself. @@ -116,15 +119,16 @@ export class Stack extends Construct implements ITaggable { public readonly name: string; /** - * Should the Stack be deployed when running `cdk deploy` without arguments - * (and listed when running `cdk synth` without arguments). - * Setting this to `false` is useful when you have a Stack in your CDK app - * that you don't want to deploy using the CDK toolkit - - * for example, because you're planning on deploying it through CodePipeline. + * Indicates if this stack is hidden, which means that it won't be + * automatically deployed when running `cdk deploy` without arguments. + * + * Setting this to `true` is useful when you have a stack in your CDK app + * which you don't want to deploy using the CDK toolkit. For example, because + * you're planning on deploying it through a deployment pipeline. * - * By default, this is `true`. + * By default, all stacks are visible (this will be false). */ - public readonly autoDeploy: boolean; + public readonly hidden: boolean; /** * Other stacks this stack depends on @@ -159,9 +163,9 @@ export class Stack extends Construct implements ITaggable { this.configuredEnv = props.env || {}; this.env = this.parseEnvironment(props.env); - this.logicalIds = new LogicalIDs(props && props.namingScheme ? props.namingScheme : new HashedAddressingScheme()); + this.logicalIds = new LogicalIDs(props.namingScheme ? props.namingScheme : new HashedAddressingScheme()); this.name = props.stackName !== undefined ? props.stackName : this.calculateStackName(); - this.autoDeploy = props && props.autoDeploy === false ? false : true; + this.hidden = props.hidden === undefined ? false : true; this.tags = new TagManager(TagType.KeyValue, "aws:cdk:stack", props.tags); if (!Stack.VALID_STACK_NAME_REGEX.test(this.name)) { @@ -509,7 +513,8 @@ export class Stack extends Construct implements ITaggable { } } - protected synthesize(builder: cxapi.CloudAssemblyBuilder): void { + protected synthesize(session: ISynthesisSession): void { + const builder = session.assembly; const template = `${this.name}.template.json`; // write the CloudFormation template as a JSON file @@ -529,7 +534,7 @@ export class Stack extends Construct implements ITaggable { type: cxapi.ArtifactType.AwsCloudFormationStack, environment: this.environment, properties, - autoDeploy: this.autoDeploy ? undefined : false, + hidden: this.hidden ? true : undefined, // omit if stack is visible dependencies: deps.length > 0 ? deps : undefined, metadata: Object.keys(meta).length > 0 ? meta : undefined, missing: this.missingContext && Object.keys(this.missingContext).length > 0 ? this.missingContext : undefined diff --git a/packages/@aws-cdk/cdk/lib/synthesis.ts b/packages/@aws-cdk/cdk/lib/synthesis.ts index 32a684683b6f2..baa25cc6913aa 100644 --- a/packages/@aws-cdk/cdk/lib/synthesis.ts +++ b/packages/@aws-cdk/cdk/lib/synthesis.ts @@ -1,8 +1,15 @@ import { BuildOptions, CloudAssembly, CloudAssemblyBuilder } from '@aws-cdk/cx-api'; import { ConstructOrder, IConstruct } from './construct'; +export interface ISynthesisSession { + /** + * The cloud assembly being synthesized. + */ + assembly: CloudAssemblyBuilder; +} + export interface ISynthesizable { - synthesize(session: CloudAssemblyBuilder): void; + synthesize(session: ISynthesisSession): void; } export interface SynthesisOptions extends BuildOptions { @@ -21,7 +28,7 @@ export interface SynthesisOptions extends BuildOptions { export class Synthesizer { public synthesize(root: IConstruct, options: SynthesisOptions = { }): CloudAssembly { - const session = new CloudAssemblyBuilder(options.outdir); + const builder = new CloudAssemblyBuilder(options.outdir); // the three holy phases of synthesis: prepare, validate and synthesize @@ -41,12 +48,12 @@ export class Synthesizer { // synthesize (leaves first) for (const c of root.node.findAll(ConstructOrder.PostOrder)) { if (isSynthesizable(c)) { - c.synthesize(session); + c.synthesize({ assembly: builder }); } } // write session manifest and lock store - return session.build(options); + return builder.build(options); } } diff --git a/packages/@aws-cdk/cdk/test/test.app.ts b/packages/@aws-cdk/cdk/test/test.app.ts index 77ee96c813bb1..8845eb9348031 100644 --- a/packages/@aws-cdk/cdk/test/test.app.ts +++ b/packages/@aws-cdk/cdk/test/test.app.ts @@ -60,7 +60,7 @@ export = { test.deepEqual(stack1.template, { Resources: { s1c1: { Type: 'DummyResource', Properties: { Prop1: 'Prop1' } }, s1c2: { Type: 'DummyResource', Properties: { Foo: 123 } } } }); - test.deepEqual(stack1.metadata, { + test.deepEqual(stack1.manifest.metadata, { '/stack1': [{ type: 'meta', data: 111 }], '/stack1/s1c1': [{ type: 'aws:cdk:logicalId', data: 's1c1' }], '/stack1/s1c2': @@ -77,7 +77,7 @@ export = { { s2c1: { Type: 'DummyResource', Properties: { Prog2: 'Prog2' } }, s1c2r1D1791C01: { Type: 'ResourceType1' }, s1c2r25F685FFF: { Type: 'ResourceType2' } } }); - test.deepEqual(stack2.metadata, { + test.deepEqual(stack2.manifest.metadata, { '/stack2/s2c1': [{ type: 'aws:cdk:logicalId', data: 's2c1' }], '/stack2/s1c2': [{ type: 'meta', data: { key: 'value' } }], '/stack2/s1c2/r1': @@ -193,7 +193,7 @@ export = { new MyStack(app, 'MyStack'); }); - test.deepEqual(response.stacks[0].missing, { + test.deepEqual(response.missing, { "missing-context-key": { provider: 'fake', props: { diff --git a/packages/@aws-cdk/cdk/test/test.context.ts b/packages/@aws-cdk/cdk/test/test.context.ts index d701a725153f1..b11dd432f0b7f 100644 --- a/packages/@aws-cdk/cdk/test/test.context.ts +++ b/packages/@aws-cdk/cdk/test/test.context.ts @@ -102,7 +102,7 @@ export = { const assembly = app.run(); const output = assembly.getStack('test-stack'); - const metadata = output.metadata; + const metadata = output.manifest.metadata || {}; const azError: cxapi.MetadataEntry | undefined = metadata['/test-stack'].find(x => x.type === cxapi.ERROR_METADATA_KEY); const ssmError: cxapi.MetadataEntry | undefined = metadata['/test-stack/ChildConstruct'].find(x => x.type === cxapi.ERROR_METADATA_KEY); diff --git a/packages/@aws-cdk/cdk/test/test.synthesis.ts b/packages/@aws-cdk/cdk/test/test.synthesis.ts index 9b6f8e64f4bb1..00716cb1a2e65 100644 --- a/packages/@aws-cdk/cdk/test/test.synthesis.ts +++ b/packages/@aws-cdk/cdk/test/test.synthesis.ts @@ -5,7 +5,7 @@ import { Test } from 'nodeunit'; import os = require('os'); import path = require('path'); import cdk = require('../lib'); -import { Construct, Synthesizer } from '../lib'; +import { Construct, ISynthesisSession, Synthesizer } from '../lib'; function createModernApp() { return new cdk.App({ @@ -53,9 +53,9 @@ export = { const stack = new cdk.Stack(app, 'one-stack'); class MyConstruct extends cdk.Construct implements cdk.ISynthesizable { - public synthesize(s: cxapi.CloudAssemblyBuilder) { - writeJson(s.outdir, 'foo.json', { bar: 123 }); - s.addArtifact('my-random-construct', { + public synthesize(s: ISynthesisSession) { + writeJson(s.assembly.outdir, 'foo.json', { bar: 123 }); + s.assembly.addArtifact('my-random-construct', { type: cxapi.ArtifactType.AwsCloudFormationStack, environment: 'aws://12345/bar', properties: { @@ -118,7 +118,13 @@ export = { session.addArtifact('art', { type: cxapi.ArtifactType.AwsCloudFormationStack, - properties: { templateFile: 'hey.json' }, + properties: { + templateFile: 'hey.json', + parameters: { + paramId: 'paramValue', + paramId2: 'paramValue2' + } + }, environment: 'aws://unknown-account/us-east-1' }); @@ -134,7 +140,8 @@ export = { test.deepEqual(calls, [ 'prepare', 'validate', 'synthesize' ]); const stack = assembly.getStack('art'); test.deepEqual(stack.template, { hello: 123 }); - test.deepEqual(stack.properties, { templateFile: 'hey.json' }); + test.deepEqual(stack.templateFile, 'hey.json'); + test.deepEqual(stack.parameters, { paramId: 'paramValue', paramId2: 'paramValue2' }); test.deepEqual(stack.environment, { region: 'us-east-1', account: 'unknown-account', name: 'aws://unknown-account/us-east-1' }); test.done(); }, @@ -150,8 +157,8 @@ export = { // THEN const session = app.run(); - const props = session.getStack('my-stack').properties; - test.deepEqual(props.parameters, { + const artifact = session.getStack('my-stack'); + test.deepEqual(artifact.parameters, { MyParam: 'Foo' }); test.done(); diff --git a/packages/@aws-cdk/cx-api/lib/cloud-artifact.ts b/packages/@aws-cdk/cx-api/lib/cloud-artifact.ts index 159b00ac161be..21a1652bfe79a 100644 --- a/packages/@aws-cdk/cx-api/lib/cloud-artifact.ts +++ b/packages/@aws-cdk/cx-api/lib/cloud-artifact.ts @@ -1,64 +1,141 @@ import { CloudAssembly, MissingContext } from './cloud-assembly'; import { Environment, EnvironmentUtils } from './environment'; -import { ERROR_METADATA_KEY, INFO_METADATA_KEY, MetadataEntry, SynthesisMessage, SynthesisMessageLevel, WARNING_METADATA_KEY } from './metadata'; - +import { + ERROR_METADATA_KEY, + INFO_METADATA_KEY, + MetadataEntry, + MetadataEntryResult, + SynthesisMessage, + SynthesisMessageLevel, + WARNING_METADATA_KEY } from './metadata'; + +/** + * Type of cloud artifact. + */ export enum ArtifactType { - None = 'none', + None = 'none', // required due to a jsii bug + + /** + * The artifact is an AWS CloudFormation stack. + */ AwsCloudFormationStack = 'aws:cloudformation:stack', } -export interface Artifact { +/** + * A manifest for a single artifact within the cloud assembly. + */ +export interface ArtifactManifest { + /** + * The type of artifact. + */ readonly type: ArtifactType; + + /** + * The environment into which this artifact is deployed. + */ readonly environment: string; // format: aws://account/region + + /** + * Associated metadata. + */ readonly metadata?: { [path: string]: MetadataEntry[] }; + + /** + * IDs of artifacts that must be deployed before this artifact. + */ readonly dependencies?: string[]; + + /** + * Any missing context information. + */ readonly missing?: { [key: string]: MissingContext }; + + /** + * The set of properties for this artifact (depends on type) + */ readonly properties?: { [name: string]: any }; - readonly autoDeploy?: boolean; + + /** + * True if this artifact should be be explicitly deployed or is it a "hidden" artifact. + */ + readonly hidden?: boolean; } +/** + * Artifact properties for CloudFormation stacks. + */ export interface AwsCloudFormationStackProperties { + /** + * A file relative to the assembly root which contains the CloudFormation template for this stack. + */ readonly templateFile: string; + + /** + * Values for CloudFormation stack parameters that should be passed when the stack is deployed. + */ readonly parameters?: { [id: string]: string }; } +/** + * Represents an artifact within a cloud assembly. + */ export class CloudArtifact { - public static from(assembly: CloudAssembly, name: string, artifact: Artifact): CloudArtifact { + /** + * Returns a subclass of `CloudArtifact` based on the artifact type defined in the artifact manifest. + * @param assembly The cloud assembly from which to load the artifact + * @param id The artifact ID + * @param artifact The artifact manifest + */ + public static from(assembly: CloudAssembly, id: string, artifact: ArtifactManifest): CloudArtifact { switch (artifact.type) { case ArtifactType.AwsCloudFormationStack: - return new CloudFormationStackArtifact(assembly, name, artifact); + return new CloudFormationStackArtifact(assembly, id, artifact); default: throw new Error(`unsupported artifact type: ${artifact.type}`); } } - public readonly type: ArtifactType; - public readonly missing: { [key: string]: MissingContext }; - public readonly autoDeploy: boolean; + /** + * The artifact's manifest + */ + public readonly manifest: ArtifactManifest; + + /** + * The set of messages extracted from the artifact's metadata. + */ public readonly messages: SynthesisMessage[]; + + /** + * The environment into which to deploy this artifact. + */ public readonly environment: Environment; - public readonly metadata: { [path: string]: MetadataEntry[] }; - public readonly dependsIDs: string[]; - public readonly properties: { [name: string]: any }; - - private _deps?: CloudArtifact[]; // cache - - constructor(public readonly assembly: CloudAssembly, public readonly id: string, artifact: Artifact) { - this.missing = artifact.missing || { }; - this.type = artifact.type; - this.environment = EnvironmentUtils.parse(artifact.environment); - this.autoDeploy = artifact.autoDeploy === undefined ? true : artifact.autoDeploy; - this.metadata = artifact.metadata || { }; + + /** + * IDs of all dependencies. Used when topologically sorting the artifacts within the cloud assembly. + * @internal + */ + public readonly _dependencyIDs: string[]; + + /** + * Cache of resolved dependencies. + */ + private _deps?: CloudArtifact[]; + + protected constructor(public readonly assembly: CloudAssembly, public readonly id: string, manifest: ArtifactManifest) { + this.manifest = manifest; + this.environment = EnvironmentUtils.parse(manifest.environment); this.messages = this.renderMessages(); - this.dependsIDs = artifact.dependencies || []; - this.properties = artifact.properties || { }; + this._dependencyIDs = manifest.dependencies || []; } - public get depends(): CloudArtifact[] { + /** + * Returns all the artifacts that this artifact depends on. + */ + public get dependencies(): CloudArtifact[] { if (this._deps) { return this._deps; } - this._deps = this.dependsIDs.map(id => { + this._deps = this._dependencyIDs.map(id => { const dep = this.assembly.artifacts.find(a => a.id === id); if (!dep) { throw new Error(`Artifact ${this.id} depends on non-existing artifact ${id}`); @@ -69,10 +146,26 @@ export class CloudArtifact { return this._deps; } + /** + * @returns all the metadata entries of a specific type in this artifact. + * @param type + */ + public findMetadataByType(type: string) { + const result = new Array(); + for (const path of Object.keys(this.manifest.metadata || {})) { + for (const entry of (this.manifest.metadata || {})[path]) { + if (entry.type === type) { + result.push({ path, ...entry }); + } + } + } + return result; + } + private renderMessages() { const messages = new Array(); - for (const [ id, metadata ] of Object.entries(this.metadata)) { + for (const [ id, metadata ] of Object.entries(this.manifest.metadata || { })) { for (const entry of metadata) { let level: SynthesisMessageLevel; switch (entry.type) { diff --git a/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts b/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts index f4c0f9c037c83..8aa3416a6731c 100644 --- a/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts +++ b/packages/@aws-cdk/cx-api/lib/cloud-assembly.ts @@ -1,11 +1,14 @@ import fs = require('fs'); import os = require('os'); import path = require('path'); -import { Artifact, CloudArtifact } from './cloud-artifact'; +import { ArtifactManifest, CloudArtifact } from './cloud-artifact'; import { CloudFormationStackArtifact } from './cloudformation-artifact'; import { topologicalSort } from './toposort'; import { CLOUD_ASSEMBLY_VERSION, verifyManifestVersion } from './versioning'; +/** + * A manifest which describes the cloud assembly. + */ export interface AssemblyManifest { /** * Protocol version @@ -15,7 +18,7 @@ export interface AssemblyManifest { /** * The set of artifacts in this assembly. */ - readonly artifacts?: { [id: string]: Artifact }; + readonly artifacts?: { [id: string]: ArtifactManifest }; /** * Runtime information. @@ -28,15 +31,47 @@ export interface AssemblyManifest { */ const MANIFEST_FILE = 'manifest.json'; +/** + * Represents a deployable cloud application. + */ export class CloudAssembly { - public readonly artifacts: CloudArtifact[]; + /** + * The root directory of the cloud assembly. + */ + public readonly directory: string; + + /** + * The schema version of the assembly manifest. + */ public readonly version: string; + + /** + * All artifacts included in this assembly. + */ + public readonly artifacts: CloudArtifact[]; + + /** + * The set of missing context information (or `undefined` if there is no missing context). + */ public readonly missing?: { [key: string]: MissingContext }; + + /** + * Runtime information such as module versions used to synthesize this assembly. + */ public readonly runtime: RuntimeInfo; + + /** + * The raw assembly manifest. + */ public readonly manifest: AssemblyManifest; - constructor(public readonly directory: string) { - this.manifest = this.readJson(MANIFEST_FILE); + /** + * Reads a cloud assembly from the specified directory. + * @param directory The root directory of the assembly. + */ + constructor(directory: string) { + this.directory = directory; + this.manifest = JSON.parse(fs.readFileSync(path.join(directory, MANIFEST_FILE), 'UTF-8')); this.version = this.manifest.version; verifyManifestVersion(this.version); @@ -49,27 +84,37 @@ export class CloudAssembly { this.validateDeps(); } + /** + * Attempts to find an artifact with a specific identity. + * @returns A `CloudArtifact` object or `undefined` if the artifact does not exist in this assembly. + * @param id The artifact ID + */ public tryGetArtifact(id: string): CloudArtifact | undefined { return this.stacks.find(a => a.id === id); } - public getStack(id: string): CloudFormationStackArtifact { - const artifact = this.tryGetArtifact(id); + /** + * Returns a CloudFormation stack artifact from this assembly. + * @param stackName the name of the CloudFormation stack. + * @throws if there is no stack artifact by that name + * @returns a `CloudFormationStackArtifact` object. + */ + public getStack(stackName: string): CloudFormationStackArtifact { + const artifact = this.tryGetArtifact(stackName); if (!artifact) { - throw new Error(`Unable to find artifact with id "${id}"`); + throw new Error(`Unable to find artifact with id "${stackName}"`); } if (!(artifact instanceof CloudFormationStackArtifact)) { - throw new Error(`Artifact ${id} is not a CloudFormation stack`); + throw new Error(`Artifact ${stackName} is not a CloudFormation stack`); } return artifact; } - public readJson(filePath: string) { - return JSON.parse(fs.readFileSync(path.join(this.directory, filePath), 'utf-8')); - } - + /** + * @returns all the CloudFormation stack artifacts that are included in this assembly. + */ public get stacks(): CloudFormationStackArtifact[] { const result = new Array(); for (const a of this.artifacts) { @@ -82,7 +127,7 @@ export class CloudAssembly { private validateDeps() { for (const artifact of this.artifacts) { - ignore(artifact.depends); + ignore(artifact.dependencies); } } @@ -92,13 +137,13 @@ export class CloudAssembly { result.push(CloudArtifact.from(this, name, artifact)); } - return topologicalSort(result, x => x.id, x => x.dependsIDs); + return topologicalSort(result, x => x.id, x => x._dependencyIDs); } private renderMissing() { const missing: { [key: string]: MissingContext } = { }; - for (const artifact of this.artifacts) { - for (const [ key, m ] of Object.entries(artifact.missing)) { + for (const artifact of Object.values(this.manifest.artifacts || {})) { + for (const [ key, m ] of Object.entries(artifact.missing || {})) { missing[key] = m; } } @@ -107,11 +152,21 @@ export class CloudAssembly { } } +/** + * Can be used to build a cloud assembly. + */ export class CloudAssemblyBuilder { + /** + * The root directory of the resulting cloud assembly. + */ public readonly outdir: string; - private readonly artifacts: { [id: string]: Artifact } = { }; + private readonly artifacts: { [id: string]: ArtifactManifest } = { }; + /** + * Initializes a cloud assembly builder. + * @param outdir The output directory, uses temporary directory if undefined + */ constructor(outdir?: string) { this.outdir = outdir || fs.mkdtempSync(path.join(os.tmpdir(), 'cdk.out')); @@ -129,8 +184,13 @@ export class CloudAssemblyBuilder { } } - public addArtifact(name: string, artifact: Artifact) { - this.artifacts[name] = filterUndefined(artifact); + /** + * Adds an artifact into the cloud assembly. + * @param id The ID of the artifact. + * @param manifest The artifact manifest + */ + public addArtifact(id: string, manifest: ArtifactManifest) { + this.artifacts[id] = filterUndefined(manifest); } public build(options: BuildOptions = { }): CloudAssembly { diff --git a/packages/@aws-cdk/cx-api/lib/cloudformation-artifact.ts b/packages/@aws-cdk/cx-api/lib/cloudformation-artifact.ts index ff113472cbfb1..551d92cc7015d 100644 --- a/packages/@aws-cdk/cx-api/lib/cloudformation-artifact.ts +++ b/packages/@aws-cdk/cx-api/lib/cloudformation-artifact.ts @@ -1,55 +1,52 @@ +import fs = require('fs'); +import path = require('path'); import { ASSET_METADATA, AssetMetadataEntry } from './assets'; -import { Artifact, AwsCloudFormationStackProperties, CloudArtifact } from './cloud-artifact'; +import { ArtifactManifest, AwsCloudFormationStackProperties, CloudArtifact } from './cloud-artifact'; import { CloudAssembly } from './cloud-assembly'; export class CloudFormationStackArtifact extends CloudArtifact { + /** + * The CloudFormation template for this stack. + */ public readonly template: any; + + /** + * The file name of the template. + */ + public readonly templateFile: string; + + /** + * The original name as defined in the CDK app. + */ public readonly originalName: string; - public readonly logicalIdToPathMap: { [logicalId: string]: string }; + + /** + * Any assets associated with this stack. + */ public readonly assets: AssetMetadataEntry[]; + /** + * CloudFormation parameters to pass to the stack. + */ + public readonly parameters: { [id: string]: string }; + + /** + * The name of this stack. This is read/write and can be used to rename the stack. + */ public name: string; - constructor(assembly: CloudAssembly, name: string, artifact: Artifact) { + constructor(assembly: CloudAssembly, name: string, artifact: ArtifactManifest) { super(assembly, name, artifact); if (!artifact.properties || !artifact.properties.templateFile) { throw new Error(`Invalid CloudFormation stack artifact. Missing "templateFile" property in cloud assembly manifest`); } + const properties = (this.manifest.properties || {}) as AwsCloudFormationStackProperties; + this.templateFile = properties.templateFile; + this.parameters = properties.parameters || { }; - const properties = this.properties as AwsCloudFormationStackProperties; - this.template = this.assembly.readJson(properties.templateFile); - this.originalName = name; - this.name = this.originalName; - this.logicalIdToPathMap = this.buildLogicalToPathMap(); - this.assets = this.buildAssets(); - } - - private buildAssets() { - const assets = new Array(); - - for (const k of Object.keys(this.metadata)) { - for (const entry of this.metadata[k]) { - if (entry.type === ASSET_METADATA) { - assets.push(entry.data); - } - } - } - - return assets; - } - - private buildLogicalToPathMap() { - const map: { [id: string]: string } = {}; - for (const cpath of Object.keys(this.metadata)) { - const md = this.metadata[cpath]; - for (const e of md) { - if (e.type === 'aws:cdk:logicalId') { - const logical = e.data; - map[logical] = cpath; - } - } - } - return map; + this.name = this.originalName = name; + this.template = JSON.parse(fs.readFileSync(path.join(this.assembly.directory, this.templateFile), 'utf-8')); + this.assets = this.findMetadataByType(ASSET_METADATA).map(e => e.data); } } diff --git a/packages/@aws-cdk/cx-api/lib/metadata.ts b/packages/@aws-cdk/cx-api/lib/metadata.ts index 49957c3d1403e..4eec14a69f3ce 100644 --- a/packages/@aws-cdk/cx-api/lib/metadata.ts +++ b/packages/@aws-cdk/cx-api/lib/metadata.ts @@ -19,6 +19,11 @@ export const ERROR_METADATA_KEY = 'aws:cdk:error'; */ export const PATH_METADATA_KEY = 'aws:cdk:path'; +/** + * Represents the CloudFormation logical ID of a resource at a certain path. + */ +export const LOGICAL_ID_METADATA_KEY = 'aws:cdk:logicalId'; + /** * Tag metadata key. */ @@ -49,6 +54,13 @@ export interface MetadataEntry { readonly trace?: string[]; } +export interface MetadataEntryResult extends MetadataEntry { + /** + * The path in which this entry was defined. + */ + readonly path: string; +} + /** * Metadata associated with the objects in the stack's Construct tree */ diff --git a/packages/@aws-cdk/cx-api/test/cloud-assembly.test.ts b/packages/@aws-cdk/cx-api/test/cloud-assembly.test.ts index 41d11d12094f4..4736659cdede2 100644 --- a/packages/@aws-cdk/cx-api/test/cloud-assembly.test.ts +++ b/packages/@aws-cdk/cx-api/test/cloud-assembly.test.ts @@ -24,16 +24,15 @@ test('assembly a single cloudformation stack', () => { const stack = assembly.stacks[0]; expect(stack.assets).toHaveLength(0); - expect(stack.autoDeploy).toBeTruthy(); - expect(stack.depends).toEqual([]); + expect(stack.manifest.hidden).toBeFalsy(); + expect(stack.dependencies).toEqual([]); expect(stack.environment).toEqual({ account: '37736633', region: 'us-region-1', name: 'aws://37736633/us-region-1' }); expect(stack.template).toEqual({ Resources: { MyBucket: { Type: "AWS::S3::Bucket" } } }); expect(stack.messages).toEqual([]); - expect(stack.metadata).toEqual({}); - expect(stack.missing).toEqual({}); + expect(stack.manifest.metadata).toEqual(undefined); + expect(stack.manifest.missing).toEqual(undefined); expect(stack.originalName).toEqual('MyStackName'); expect(stack.name).toEqual('MyStackName'); - expect(stack.logicalIdToPathMap).toEqual({}); }); test('assembly with missing context', () => { @@ -72,21 +71,16 @@ test('assets', () => { expect(assembly.stacks[0].assets).toMatchSnapshot(); }); -test('logical id to path map', () => { - const assembly = new CloudAssembly(path.join(FIXTURES, 'logical-id-map')); - expect(assembly.stacks[0].logicalIdToPathMap).toEqual({ logicalIdOfFooBar: '/foo/bar' }); -}); - test('dependencies', () => { const assembly = new CloudAssembly(path.join(FIXTURES, 'depends')); expect(assembly.stacks).toHaveLength(4); // expect stacks to be listed in topological order expect(assembly.stacks.map(s => s.name)).toEqual([ 'StackA', 'StackD', 'StackC', 'StackB' ]); - expect(assembly.stacks[0].depends).toEqual([]); - expect(assembly.stacks[1].depends).toEqual([]); - expect(assembly.stacks[2].depends.map(x => x.id)).toEqual([ 'StackD' ]); - expect(assembly.stacks[3].depends.map(x => x.id)).toEqual([ 'StackC', 'StackD' ]); + expect(assembly.stacks[0].dependencies).toEqual([]); + expect(assembly.stacks[1].dependencies).toEqual([]); + expect(assembly.stacks[2].dependencies.map(x => x.id)).toEqual([ 'StackD' ]); + expect(assembly.stacks[3].dependencies.map(x => x.id)).toEqual([ 'StackC', 'StackD' ]); }); test('fails for invalid dependencies', () => { diff --git a/packages/aws-cdk/bin/cdk.ts b/packages/aws-cdk/bin/cdk.ts index dc65005ad76c3..8c5b259fdc23f 100644 --- a/packages/aws-cdk/bin/cdk.ts +++ b/packages/aws-cdk/bin/cdk.ts @@ -227,7 +227,7 @@ async function initCommandLine() { async function cliMetadata(stackName: string) { const s = await appStacks.synthesizeStack(stackName); - return s.metadata; + return s.manifest.metadata || {}; } /** diff --git a/packages/aws-cdk/lib/api/cxapp/stacks.ts b/packages/aws-cdk/lib/api/cxapp/stacks.ts index f0691bc85edc9..f1341bcbaadf3 100644 --- a/packages/aws-cdk/lib/api/cxapp/stacks.ts +++ b/packages/aws-cdk/lib/api/cxapp/stacks.ts @@ -91,10 +91,10 @@ export class AppStacks { if (selectors.length === 0) { // remove non-auto deployed Stacks - const autoDeployedStacks = stacks.filter(s => s.autoDeploy); - debug('Stack name not specified, so defaulting to all available stacks: ' + listStackNames(autoDeployedStacks)); - this.applyRenames(autoDeployedStacks); - return autoDeployedStacks; + const visibleStacks = stacks.filter(s => !s.manifest.hidden); + debug('Stack name not specified, so defaulting to all available stacks: ' + listStackNames(visibleStacks)); + this.applyRenames(visibleStacks); + return visibleStacks; } const allStacks = new Map(); @@ -158,12 +158,8 @@ export class AppStacks { */ public async synthesizeStack(stackName: string): Promise { const resp = await this.synthesizeStacks(); - const stack = resp.stacks.find(s => s.name === stackName); - if (!stack) { - throw new Error(`Stack ${stackName} not found`); - } + const stack = resp.getStack(stackName); this.applyRenames([stack]); - return stack; } @@ -235,18 +231,10 @@ export class AppStacks { } /** - * Returns and array with the tags available in the stack metadata. + * @returns an array with the tags available in the stack metadata. */ public getTagsFromStackMetadata(stack: cxapi.CloudFormationStackArtifact): Tag[] { - for (const id of Object.keys(stack.metadata)) { - const metadata = stack.metadata[id]; - for (const entry of metadata) { - if (entry.type === cxapi.STACK_TAGS_METADATA_KEY) { - return entry.data; - } - } - } - return []; + return stack.findMetadataByType(cxapi.STACK_TAGS_METADATA_KEY).map(x => x.data); } /** @@ -342,7 +330,7 @@ function includeDownstreamStacks( for (const [name, stack] of allStacks) { // Select this stack if it's not selected yet AND it depends on a stack that's in the selected set - if (!selectedStacks.has(name) && (stack.depends || []).some(dep => selectedStacks.has(dep.id))) { + if (!selectedStacks.has(name) && (stack.dependencies || []).some(dep => selectedStacks.has(dep.id))) { selectedStacks.set(name, stack); added.push(name); madeProgress = true; @@ -370,7 +358,7 @@ function includeUpstreamStacks( for (const stack of selectedStacks.values()) { // Select an additional stack if it's not selected yet and a dependency of a selected stack (and exists, obviously) - for (const dependencyName of stack.depends.map(x => x.id)) { + for (const dependencyName of stack.dependencies.map(x => x.id)) { if (!selectedStacks.has(dependencyName) && allStacks.has(dependencyName)) { added.push(dependencyName); selectedStacks.set(dependencyName, allStacks.get(dependencyName)!); diff --git a/packages/aws-cdk/lib/api/util/cloudformation/stack-activity-monitor.ts b/packages/aws-cdk/lib/api/util/cloudformation/stack-activity-monitor.ts index f54a6a7261914..f103b3bf3a26e 100644 --- a/packages/aws-cdk/lib/api/util/cloudformation/stack-activity-monitor.ts +++ b/packages/aws-cdk/lib/api/util/cloudformation/stack-activity-monitor.ts @@ -237,12 +237,15 @@ export class StackActivityMonitor { } private findMetadataFor(logicalId: string | undefined): { entry: cxapi.MetadataEntry, path: string } | undefined { - const metadata = this.stack.metadata; + const metadata = this.stack.manifest.metadata; if (!logicalId || !metadata) { return undefined; } for (const path of Object.keys(metadata)) { - const entry = metadata[path].filter(e => e.type === 'aws:cdk:logicalId') - .find(e => e.data === logicalId); - if (entry) { return { entry, path }; } + const entry = metadata[path] + .filter(e => e.type === cxapi.LOGICAL_ID_METADATA_KEY) + .find(e => e.data === logicalId); + if (entry) { + return { entry, path }; + } } return undefined; } diff --git a/packages/aws-cdk/lib/diff.ts b/packages/aws-cdk/lib/diff.ts index 35711733bd6da..989f8db59429c 100644 --- a/packages/aws-cdk/lib/diff.ts +++ b/packages/aws-cdk/lib/diff.ts @@ -39,7 +39,7 @@ export function printStackDiff( } if (!diff.isEmpty) { - cfnDiff.formatDifferences(stream || process.stderr, diff, newTemplate.logicalIdToPathMap, context); + cfnDiff.formatDifferences(stream || process.stderr, diff, buildLogicalToPathMap(newTemplate), context); } else { print(colors.green('There were no differences')); } @@ -68,7 +68,7 @@ export function printSecurityDiff(oldTemplate: any, newTemplate: cxapi.CloudForm warning(`This deployment will make potentially sensitive changes according to your current security approval level (--require-approval ${requireApproval}).`); warning(`Please confirm you intend to make the following modifications:\n`); - cfnDiff.formatSecurityChanges(process.stdout, diff, newTemplate.logicalIdToPathMap); + cfnDiff.formatSecurityChanges(process.stdout, diff, buildLogicalToPathMap(newTemplate)); return true; } return false; @@ -88,3 +88,11 @@ function difRequiresApproval(diff: cfnDiff.TemplateDiff, requireApproval: Requir default: throw new Error(`Unrecognized approval level: ${requireApproval}`); } } + +function buildLogicalToPathMap(stack: cxapi.CloudFormationStackArtifact) { + const map: { [id: string]: string } = {}; + for (const md of stack.findMetadataByType(cxapi.LOGICAL_ID_METADATA_KEY)) { + map[md.data] = md.path; + } + return map; +} diff --git a/packages/aws-cdk/test/api/test.stacks.ts b/packages/aws-cdk/test/api/test.stacks.ts index fcce58ded3223..6977a5ddc0ba4 100644 --- a/packages/aws-cdk/test/api/test.stacks.ts +++ b/packages/aws-cdk/test/api/test.stacks.ts @@ -12,7 +12,7 @@ const FIXED_RESULT = testAssembly({ }, { stackName: 'witherrors', - autoDeploy: true, + hidden: false, template: { resource: 'errorresource' }, metadata: { '/resource': [ @@ -86,7 +86,7 @@ export = { { stackName: 'NotAutoDeployedStack', template: { resource: 'Resource' }, - autoDeploy: false, + hidden: true, }, ]); @@ -105,7 +105,7 @@ export = { { stackName: 'NotAutoDeployedStack', template: { resource: 'Resource' }, - autoDeploy: false, + hidden: true, }, ]); @@ -124,11 +124,11 @@ export = { { stackName: 'NotAutoDeployedStack', template: { resource: 'Resource' }, - autoDeploy: false, + hidden: true, }, { stackName: 'AutoDeployedStack', - autoDeploy: true, + hidden: false, depends: [ 'NotAutoDeployedStack' ], template: { resource: 'Resource' }, }, diff --git a/packages/aws-cdk/test/util.ts b/packages/aws-cdk/test/util.ts index 0e413a1da1312..60238ee581c64 100644 --- a/packages/aws-cdk/test/util.ts +++ b/packages/aws-cdk/test/util.ts @@ -5,7 +5,7 @@ import path = require('path'); export interface TestStackArtifact { stackName: string; template: any; - autoDeploy?: boolean; + hidden?: boolean; depends?: string[]; metadata?: cxapi.StackMetadata; assets?: cxapi.AssetMetadataEntry[]; @@ -29,7 +29,7 @@ export function testAssembly(...stacks: TestStackArtifact[]): cxapi.CloudAssembl builder.addArtifact(stack.stackName, { type: cxapi.ArtifactType.AwsCloudFormationStack, environment: 'aws://12345/here', - autoDeploy: stack.autoDeploy, + hidden: stack.hidden, dependencies: stack.depends, metadata, properties: {