diff --git a/design/cloud-assembly.md b/design/cloud-assembly.md new file mode 100644 index 0000000000000..63d8d808ceda1 --- /dev/null +++ b/design/cloud-assembly.md @@ -0,0 +1,473 @@ +# Cloud Assembly Specification, Version 1.0 + +The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, +**RECOMMENDED**, **MAY**, and **OPTIONAL** in this document are to be interpreted as described in [RFC 2119] when they +are spelled out in bold, capital letters (as they are shown here). + +## Introduction +A *Cloud Assembly* is a self-contained document container designed to hold the components of *cloud applications*, +including all the parts that are needed in order to deploy those to a *cloud* provider. This document is the +specification of the *Cloud Assembly* format as well as requirements imposed on *Cloud Assemblers* and *Cloud Runtimes*. + +### Design Goals +The design goals for the *Cloud Assembly Specification* are the following: +* The *Cloud Assembly Specification* is extensible. +* The *Cloud Assembly Specification* is cloud-agnostic. +* The *Cloud Assembly Specification* is easy to implement and use. +* The *Cloud Assembly Specification* supports authenticity and integrity guarantees. +* A *Cloud Assembly* is self-contained, making deployments reproductible. + +## Specification +A *Cloud Assembly* is a ZIP archive that **SHOULD** conform to the [ISO/IEC 21320-1:2015] *Document Container File* +standard. *Cloud Assembly* files **SHOULD** use the `.cloud` extension in order to make them easier to recognize by +users. + +Documents in the archive can be stored with any name and directory structure, however the following entries at the root +of the archive are reserved for special use: +* `manifest.json` **MUST** be present and contains the [manifest document](#manifest-document) for the *Cloud Assembly*. +* `signature.asc`, when present, **MUST** contain the [digital signature](#digital-signature) of the *Cloud Assembly*. + +### Manifest Document +The `manifest.json` file is the entry point of the *Cloud Assembly*. It **MUST** be a valid [JSON] document composed of +a single `object` that conforms to the following schema: + +Key |Type |Required|Description +--------------|---------------------|:------:|----------- +`schema` |`string` |Required|The schema for the document. **MUST** be `cloud-assembly/1.0`. +`droplets` |`Map` |Required|A mapping of [*Logical ID*](#logical-id) to [Droplet](#droplet). +`missing` |`Map`| |A mapping of context keys to [missing information](#missing). + +The [JSON] specification allows for keys to be specified multiple times in a given `object`. However, *Cloud Assembly* +consumers **MAY** assume keys are unique, and *Cloud Assemblers* **SHOULD** avoid generating duplicate keys. If +duplicate keys are present and the manifest parser permits it, the latest specified value **SHOULD** be preferred. + +### Logical ID +*Logical IDs* are `string`s that uniquely identify [Droplet](#droplet)s in the context of a *Cloud Assembly*. +* A *Logical ID* **MUST NOT** be empty. +* A *Logical ID* **SHOULD NOT** exceed `256` characters. +* A *Logical ID* **MUST** be composed of only the following ASCII printable characters: + + Upper-case letters: `A` (`0x41`) through `Z` (`0x5A`) + + Lower-case letters: `a` (`0x61`) through `z` (`0x7A`) + + Numeric characters: `0` (`0x30`) through `9` (`0x39`) + + Plus: `+` (`0x2B`) + + Minus: `-` (`0x2D`) + + Forward-slash: `/` (`0x2F`) + + Underscore: `_` (`0x5F`) +* A *Logical ID* **MUST NOT** contain the `.` (`0x2E`) character as it is used in the string substitution pattern for + cross-droplet references to separate the *Logical ID* from the *attribute* name. + +In other words, *Logical IDs* are expected to match the following regular expression: +```js +/^[A-Za-z0-9+\/_-]{1,256}$/ +``` + +### Droplet +Clouds are made of Droplets. Thet are the building blocks of *Cloud Assemblies*. They model a part of the +*cloud application* that can be deployed independently, provided its dependencies are fulfilled. Droplets are specified +using [JSON] objects that **MUST** conform to the following schema: + +Key |Type |Required|Description +-------------|----------------------|:------:|----------- +`type` |`string` |Required|The [*Droplet Type*](#droplet-type) specifier of this Droplet. +`environment`|`string` |required|The target [environment](#environment) in which Droplet is deployed. +`dependsOn` |`string[]` | |*Logical IDs* of other Droplets that must be deployed before this one. +`metadata` |`Map`| |Arbitrary named [metadata](#metadata) associated with this Droplet. +`properties` |`Map` | |The properties of this Droplet as documented by its maintainers. + +Each [Droplet Type](#droplet-type) can produce output strings that allow Droplets to provide informations that other +[Droplets](#droplet) can use when composing the *cloud application*. Each Droplet implementer is responsible to document +the output attributes it supports. References to these outputs are modeled using special `string` tokens within entries +of the `properties` section of Droplets: + +``` +${LogicalId.attributeName} + ╰───┬───╯ ╰─────┬─────╯ + │ └─ The name of the output attribute + └───────────── The Logical ID of the Droplet +``` + +The following escape sequences are valid: +* `\\` encodes the `\` literal +* `\${` encodes the `${` literal + +Deployment systems **SHOULD** return an error upon encountering an occurrence of the `\` literal that is not part of a +valid escape sequence. + +Droplets **MUST NOT** cause circular dependencies. Deployment systems **SHOULD** detect cycles and fail upon discovering +one. + +#### Droplet Type +Every Droplet has a type specifier, which allows *Cloud Assembly* consumers to know how to deploy it. The type +specifiers are `string`s that use an URI-like syntax (`protocol://path`), providing the coordinates to a reference +implementation for the Droplet behavior. + +Deployment systems **MUST** support at least one protocol, and **SHOULD** support all the protocols specified in +the following sub-sections. + +##### The `npm` protocol +Type specifiers using the `npm` protocol have the following format: +``` +npm://[@namespace/]package/ClassName[@version] +╰┬╯ ╰────┬────╯ ╰──┬──╯ ╰───┬───╯ ╰──┬──╯ + │ │ │ │ └─ Optional version specifier + │ │ │ └─────────── Fully qualified name of the Handler class + │ │ └──────────────────── Name of the NPM package + │ └────────────────────────────── Optional NPM namespace + └───────────────────────────────────────── NPM protocol specifier +``` + +#### Environment +Environments help Deployment systems determine where to deploy a particular Droplet. They are referenced by `string`s +that use an URI-like syntax (`protocol://path`). + +Deployment systems **MUST** support at least one protocol, and **SHOULD** support all the protocols specified in the +following sub-sections. + +##### The `aws` protocol +Environments using the `aws` protocol have the following format: +``` +aws://account/region +╰┬╯ ╰──┬──╯ ╰──┬─╯ + │ │ └─ The name of an AWS region (e.g: eu-west-1) + │ └───────── An AWS account ID (e.g: 123456789012) + └───────────────── AWS protocol specifier +``` + +### Metadata +Metadata can be attached to [Droplets](#droplet) to allow tools that work with *Cloud Assemblies* to share additional +information about the *cloud application*. Metadata **SHOULD NOT** be used to convey data that is necessary for +correctly process the *Cloud Assembly*, since any tool that consumes a *Cloud Assembly* **MAY** choose to ignore any or +all Metadata. + +Key |Type |Required|Description +-------|--------|:------:|----------- +`kind` |`string`|Required|A token identifying the kind of metadata. +`value`|`any` |Required|The value associated with this metadata. + +A common use-case for Metadata is reporting warning or error messages that were emitted during the creation of the +*Cloud Assembly*, so that deployment systems can present this information to users or logs. Warning and error messages +**SHOULD** set the `kind` field to `warning` and `error` respectively, and the `value` field **SHOULD** contain a single +`string`. Deployment systems **MAY** reject *Cloud Assemblies* that include [Droplets](#droplet) that carry one or more +`error` Metadata entries, and they **SHOULD** surface `warning` messages, either directly through their user interface, +or in the execution log. + +### Missing +[Droplets](#droplet) may require contextual information to be available in order to correctly participate in a +*Cloud Assembly*. When information is missing, *Cloud Assembly* producers report the missing information by adding +entries to the `missing` section of the [manifest document](#manifest-document). The values are [JSON] `object`s that +**MUST** conform to the following schema: + +Key |Type |Required|Description +---------------|-----------------|:------:|----------- +`provider` |`string` |Required|A tag that identifies the entity that should provide the information. +`props` |`Map`|Required|Properties that are required in order to obtain the missing information. + +### Digital Signature +#### Signing +*Cloud Assemblers* **SHOULD** support digital signature of *Cloud Assemblies*. When support for digital signature is +present, *Cloud Assemblers*: +* **MUST** require configuration of the [PGP][RFC 4880] key that will be used for signing. + +##### Signing Algorithm +The digital signature of *Cloud Assemblies* starts by establishing an attestation document that provides cryptographic +summary information about the contents of the signed assembly. It is a [JSON] document composed of a single `object` +with the following fields: + +Field |Type |Description +-----------|----------------------|----------- +`timestamp`|`string` |The [ISO 8601] timestamp of the attestation document creation time +`algorithm`|`string` |The hashing algorithm used to derive the `FileData` hashes. +`nonce` |`string` |The nonce used when deriving the `FileData` hashes. +`items` |`Map`|Summary information about the attested files. + +The `algorithm` field **MUST** be set to the standard identifier of a standard hashing algorithm, such as `SHA256`. +Algorithms that are vulnerable to known collision attacks **SHOULD** not be used. + +The `nonce` field **MUST** be set to a byte array generated using a cryptographically secure random number generator. A +`nonce` **MUST NOT** be re-used. It **MUST** be composed of at least `32` bytes, and **SHOULD** be the same length or +larger than the size of the output of the chosen `algorithm`. + +The `items` field **MUST** contain one entry for each file in the *Cloud Assembly*, keyed on the relative path to the +file within the container document, with a value that contains the following keys: +Key |Type |Description +------|--------|----------- +`size`|`string`|The decimal representation of the file size in bytes. +`hash`|`string`|The base-64 encoded result of hashing the file's content appended with the `nonce` using the `algorithm`. + +Here is a schematic example: +```js +{ + // When this attestation doucment was created + "timestamp": "2018-11-15T11:08:52", + // The hashing algorithm for the attestation is SHA256 + "algorithm": "SHA256", + // 32 bytes of cryptographically-secure randomness + "nonce": "2tDLdIoy1VtzLFjfzXVqzsNJHa9862y/WQgqKzC9+xs=", + "items": { + "data/data.bin": { + // The file is really 1024 times the character 'a' + "size": "1024", + // SHA256( + ) + "hash": "HIKJYDnT92EKILbFt2SOzA8dWF0YMEBHS72xLSw4lok=" + }, + /* ...other files of the assembly... */ + } +} +``` + +Once the attestation is ready, it is digitally *signed* using the configured [PGP][RFC 4880] key. The key **MUST** be +valid as of the `timestamp` field included in the attestation. The siganture **MUST** not be detached, and is +**RECOMMENDED** to use the *cleartext signature framework* described in section 7 of [RFC 4880] so the attestation can +be read by a human. + +#### Verifying +Deployment systems **SHOULD** support verifying signed *Cloud Assemblies*. If support for signature verification is not +present, a warning **MUST** be emitted when processing a *Cloud Assembly* that contains the `signature.asc` file. + +Deployment systems that support verifying signed *Cloud Assemblies*: +* **SHOULD** be configurable to *require* that an assembly is signed. When this requirement is active, an error **MUST** + be returned when attempting to deploy an un-signed *Cloud Assembly*. +* **MUST** verify the integrity and authenticity of signed *Cloud Assemblies* prior to attempting to load any file + included in it, except for `signature.asc`. + * An error **MUST** be raised if the *Cloud Assembly*'s integirty is not verified by the signature. + * An error **MUST** be raised if the [PGP][RFC 4880] key has expired according to the signature timestamp. + * An error **MUST** be raised if the [PGP][RFC 4880] key is known to have been revoked. Deployment systems **MAY** + trust locally available information pertaining to the key's validity. +* **SHOULD** allow configuration of a list of trusted [PGP][RFC 4880] keys. + +## Annex +### Examples of Droplets for the AWS Cloud +The Droplet specifications provided in this section are for illustration purpose only. + +#### `@aws-cdk/aws-cloudformation.StackDroplet` +A [*CloudFormation* stack][CFN Stack]. + +##### Properties +Property |Type |Required|Description +-------------|--------------------|:------:|----------- +`stackName` |`string` |Required|The name of the *CloudFormation* stack once deployed. +`template` |`string` |Required|The assembly-relative path to the *CloudFormation* template document. +`parameters` |`Map`| |Parameters to be passed to the [stack][CFN Stack] upon deployment. +`stackPolicy`|`string` | |The assembly-relative path to the [Stack Policy][CFN Stack Policy]. + +##### Output Attributes +Attribute |Type |Description +---------------|--------------------|----------- +`output.`|`string`|Data returned by the [*CloudFormation* Outputs][CFN Output] named `` of the stack. +`stackArn` |`string`|The ARN of the [stack][CFN Stack]. + +##### Example +```json +{ + "type": "npm://@aws-cdk/aws-cloudformation.StackDroplet", + "environment": "aws://000000000000/bermuda-triangle-1", + "properties": { + "template": "my-stack/template.yml", + "parameters": { + "bucketName": "${helperStack.output.bucketName}", + "objectKey": "${helperStack.output.objectKey}" + }, + "stackPolicy": "my-stack/policy.json" + } +} +``` + +[CFN Stack]: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/stacks.html +[CFN Stack Policy]: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/protect-stack-resources.html +[CFN Outputs]: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/outputs-section-structure.html + +#### `@aws-cdk/assets.FileDroplet` +A file that needs to be uploaded to an *S3* bucket. + +##### Properties +Property |Type |Required|Description +------------|--------|:------:|----------- +`file` |`string`|Required|The assembly-relative path to the file that will be uploaded. +`bucketName`|`string`|Required|The name of the bucket where this file will be uploaded. +`objectKey` |`string`|Required|The key at which to place the object in the bucket. + +##### Output Attributes +Attribute |Type |Description +------------|--------|----------- +`bucketName`|`string`|The name of the bucket where the file was uploaded. +`objectKey` |`string`|The key at which the file was uploaded in the bucket. + +##### Example +```json +{ + "type": "npm://@aws-cdk/assets.FileDroplet", + "environment": "aws://000000000000/bermuda-triangle-1", + "properties": { + "file": "assets/file.bin", + "bucket": "${helperStack.outputs.bucketName}", + "objectKey": "assets/da39a3ee5e6b4b0d3255bfef95601890afd80709/nifty-asset.png" + } +} +``` + +#### `@aws-cdk/aws-ecr.DockerImageDroplet` +A Docker image to be published to an *ECR* registry. + +##### Properties +Property |Type |Required|Description +------------|--------|:------:|----------- +`savedImage`|`string`|Required|The assembly-relative path to the tar archive obtained from `docker image save`. +`pushTarget`|`string`|Required|Where the image should be pushed to (e.g: `.dkr.ecr..amazon.com/`). +`tagName` |`string`| |The name of the tag to use when pushing the image (default: `latest`). + +##### Output Attributes +Attribute |Type |Description +--------------|--------|----------- +`exactImageId`|`string`|An absolute reference to the published image version (`imageName@DIGEST`). +`imageName` |`string`|The full tagged image name (`imageName:tagName`). + +##### Example +```json +{ + "type": "npm://@aws-cdk/aws-ecr.DockerImageDroplet", + "environment": "aws://000000000000/bermuda-triangle-1", + "properties": { + "savedImage": "docker/37e6de0b24fa.tar", + "imageName": "${helperStack.output.ecrImageName}", + "tagName": "latest" + } +} +``` + +### Example +Here is an example the contents of a complete *Cloud Assembly* that deploys AWS resources: +``` +☁️ my-assembly.cloud +├─ manifest.json Cloud Assembly manifest +├─ stacks +│ ├─ PipelineStack.yml CloudFormation template +│ ├─ ServiceStack-beta.yml CloudFormation template +│ ├─ ServiceStack-beta.stack-policy.json CloudFormation stack policy +│ ├─ ServiceStack-prod.yml CloudFormation template +│ └─ ServiceStack-prod.stack-policy.json CloudFormation stack policy +├─ docker +│ └─ docker-image.tar Saved Docker image (docker image save) +├─ assets +│ └─ static-website Files for a static website +│ ├─ index.html +│ └─ style.css +└─ signature.asc Cloud Assembly digital signature +``` + +#### `manifest.json` +```json +{ + "schema": "cloud-assembly/1.0", + "droplets": { + "PipelineStack": { + "type": "npm://@aws-cdk/aws-cloudformation.StackDroplet", + "environment": "aws://123456789012/eu-west-1", + "properties": { + "template": "stacks/PipelineStack.yml" + } + }, + "ServiceStack-beta": { + "type": "npm://@aws-cdk/aws-cloudformation.StackDroplet", + "environment": "aws://123456789012/eu-west-1", + "properties": { + "template": "stacks/ServiceStack-beta.yml", + "stackPolicy": "stacks/ServiceStack-beta.stack-policy.json", + "parameters": { + "image": "${DockerImage.exactImageId}", + "websiteFilesBucket": "${StaticFiles.bucketName}", + "websiteFilesKeyPrefix": "${StaticFiles.keyPrefix}", + } + } + }, + "ServiceStack-prod": { + "type": "npm://@aws-cdk/aws-cloudformation.StackDroplet", + "environment": "aws://123456789012/eu-west-1", + "properties": { + "template": "stacks/ServiceStack-prod.yml", + "stackPolicy": "stacks/ServiceStack-prod.stack-policy.json", + "parameters": { + "image": "${DockerImage.exactImageId}", + "websiteFilesBucket": "${StaticFiles.bucketName}", + "websiteFilesKeyPrefix": "${StaticFiles.keyPrefix}", + } + } + }, + "DockerImage": { + "type": "npm://@aws-cdk/aws-ecr.DockerImageDroplet", + "environment": "aws://123456789012/eu-west-1", + "properties": { + "savedImage": "docker/docker-image.tar", + "imageName": "${PipelineStack.output.ecrImageName}" + } + }, + "StaticFiles": { + "type": "npm://@aws-cdk/assets.DirectoryDroplet", + "environment": "aws://123456789012/eu-west-1", + "properties": { + "directory": "assets/static-website", + "bucketName": "${PipelineStack.output.stagingBucket}" + } + } + } +} +``` + +#### `signature.asc` +```pgp +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA256 + +{ + "algorithm": "SHA-256", + "items": { + "assets/static-website/index.html": { + "size": ..., + "hash": "..." + }, + "assets/static-website/style.css": { + "size": ..., + "hash": "..." + }, + "docker/docker-image.tar": { + "size": ..., + "hash": "..." + }, + "manifest.json": { + "size": ..., + "hash": "..." + }, + "stacks/PipelineStack.yml": { + "size": ..., + "hash": "..." + }, + "stacks/ServiceStack-beta.stack-policy.json": { + "size": ..., + "hash": "..." + }, + "stacks/ServiceStack-beta.yml": { + "size": ..., + "hash": "..." + }, + "stacks/ServiceStack-prod.stack-policy.json": { + "size": ..., + "hash": "..." + }, + "stacks/ServiceStack-prod.yml": { + "size": ..., + "hash": "..." + }, + }, + "nonce": "mUz0aYEhMlVmhJLNr5sizPKlJx1Kv38ApBc12NW6wPE=", + "timestamp": "2018-11-06T14:56:23Z" +} +-----BEGIN PGP SIGNATURE----- +[...] +-----END PGP SIGNATURE----- +``` + + +[RFC 2119]: https://tools.ietf.org/html/rfc2119 +[ISO/IEC 21320-1:2015]: https://www.iso.org/standard/60101.html +[JSON]: https://www.json.org +[RFC 4880]: https://tools.ietf.org/html/rfc4880 +[ISO 8601]: https://www.iso.org/standard/40874.html \ No newline at end of file diff --git a/packages/@aws-cdk/applet-js/test/test.applets.ts b/packages/@aws-cdk/applet-js/test/test.applets.ts index 7d8f67ad03807..1e0cd80f67fc8 100644 --- a/packages/@aws-cdk/applet-js/test/test.applets.ts +++ b/packages/@aws-cdk/applet-js/test/test.applets.ts @@ -80,16 +80,17 @@ function synthesizeApplet(yamlFile: string, direct = false) { const command = direct ? yamlFile : 'cdk-applet-js'; const args = direct ? [] : [yamlFile]; + const outdir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-applet-tests')); child_process.execFileSync(command, args, { env: { ...process.env, - CDK_OUTDIR: os.tmpdir(), + CDK_OUTDIR: outdir, PATH: 'bin:' + process.env.PATH } }); - return JSON.parse(fs.readFileSync(path.join(os.tmpdir(), 'cdk.out'), { encoding: 'utf-8' })); + return JSON.parse(fs.readFileSync(path.join(outdir, 'cdk.out'), { encoding: 'utf-8' })); } function getStack(stackName: string, allStacks: any) { diff --git a/packages/@aws-cdk/assert/lib/expect.ts b/packages/@aws-cdk/assert/lib/expect.ts index a983da0d96cd3..fa50a36088f47 100644 --- a/packages/@aws-cdk/assert/lib/expect.ts +++ b/packages/@aws-cdk/assert/lib/expect.ts @@ -41,7 +41,7 @@ function isStackClassInstance(x: api.SynthesizedStack | cdk.Stack): x is cdk.Sta function collectStackMetadata(root: cdk.ConstructNode): api.StackMetadata { const result: api.StackMetadata = {}; - for (const construct of root.findAll(cdk.ConstructOrder.DepthFirst)) { + for (const construct of root.findAll(cdk.ConstructOrder.PreOrder)) { const path = `/${root.id}/${construct.node.path}`; for (const entry of construct.node.metadata) { result[path] = result[path] || []; diff --git a/packages/@aws-cdk/cdk/lib/app.ts b/packages/@aws-cdk/cdk/lib/app.ts index b93183a51b680..fb9a73b53bc62 100644 --- a/packages/@aws-cdk/cdk/lib/app.ts +++ b/packages/@aws-cdk/cdk/lib/app.ts @@ -1,110 +1,102 @@ import cxapi = require('@aws-cdk/cx-api'); -import fs = require('fs'); -import path = require('path'); -import { Stack } from './cloudformation/stack'; -import { IConstruct, MetadataEntry, PATH_SEP, Root } from './core/construct'; +import { ConstructOrder, Root } from './core/construct'; +import { FileSystemStore, InMemoryStore, ISynthesisSession, SynthesisSession } from './synthesis'; /** * Represents a CDK program. */ export class App extends Root { - private prepared = false; + private _session?: ISynthesisSession; + private readonly legacyManifest: boolean; + private readonly runtimeInformation: boolean; /** * Initializes a CDK application. * @param request Optional toolkit request (e.g. for tests) */ - constructor() { + constructor(context?: { [key: string]: string }) { super(); - this.loadContext(); - } - - private get stacks() { - const out: { [name: string]: Stack } = { }; - for (const child of this.node.children) { - if (!Stack.isStack(child)) { - throw new Error(`The child ${child.toString()} of App must be a Stack`); - } + this.loadContext(context); - out[child.node.id] = child as Stack; - } - return out; + // both are reverse logic + this.legacyManifest = this.node.getContext(cxapi.DISABLE_LEGACY_MANIFEST_CONTEXT) ? false : true; + this.runtimeInformation = this.node.getContext(cxapi.DISABLE_VERSION_REPORTING) ? false : true; } /** * Runs the program. Output is written to output directory as specified in the request. */ - public run(): void { + public run(): ISynthesisSession { + // this app has already been executed, no-op for you + if (this._session) { + return this._session; + } + const outdir = process.env[cxapi.OUTDIR_ENV]; - if (!outdir) { - process.stderr.write(`ERROR: The environment variable "${cxapi.OUTDIR_ENV}" is not defined\n`); - process.stderr.write('AWS CDK Toolkit (>= 0.11.0) is required in order to interact with this program.\n'); - process.exit(1); - return; + let store; + if (outdir) { + store = new FileSystemStore({ outdir }); + } else { + store = new InMemoryStore(); } - const result: cxapi.SynthesizeResponse = { - version: cxapi.PROTO_RESPONSE_VERSION, - stacks: this.synthesizeStacks(Object.keys(this.stacks)) - }; + const session = this._session = new SynthesisSession({ + store, + legacyManifest: this.legacyManifest, + runtimeInformation: this.runtimeInformation + }); + + // the three holy phases of synthesis: prepare, validate and synthesize + + // prepare + this.node.prepareTree(); + + // validate + const errors = this.node.validateTree(); + 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}`); + } - const disableVersionReporting = this.node.getContext(cxapi.DISABLE_VERSION_REPORTING); - if (!disableVersionReporting) { - result.runtime = this.collectRuntimeInformation(); + // synthesize (leaves first) + for (const c of this.node.findAll(ConstructOrder.PostOrder)) { + if (SynthesisSession.isSynthesizable(c)) { + c.synthesize(session); + } } - const outfile = path.join(outdir, cxapi.OUTFILE_NAME); - fs.writeFileSync(outfile, JSON.stringify(result, undefined, 2)); + // write session manifest and lock store + session.close(); + + return session; } /** - * Synthesize and validate a single stack + * Synthesize and validate a single stack. * @param stackName The name of the stack to synthesize + * @deprecated This method is going to be deprecated in a future version of the CDK */ public synthesizeStack(stackName: string): cxapi.SynthesizedStack { - const stack = this.getStack(stackName); - - if (!this.prepared) { - // Maintain the existing contract that the tree will be prepared even if - // 'synthesizeStack' is called by itself. But only prepare the tree once. - this.node.prepareTree(); - this.prepared = true; + if (!this.legacyManifest) { + throw new Error('No legacy manifest available, return an old-style stack output'); } - // first, validate this stack and stop if there are errors. - const errors = stack.node.validateTree(); - if (errors.length > 0) { - const errorList = errors.map(e => `[${e.source.node.path}] ${e.message}`).join('\n '); - throw new Error(`Stack validation failed with the following errors:\n ${errorList}`); + const session = this.run(); + const legacy: cxapi.SynthesizeResponse = session.store.readJson(cxapi.OUTFILE_NAME); + + const res = legacy.stacks.find(s => s.name === stackName); + if (!res) { + throw new Error(`Stack "${stackName}" not found`); } - const account = stack.env.account || 'unknown-account'; - const region = stack.env.region || 'unknown-region'; - - const environment: cxapi.Environment = { - name: `${account}/${region}`, - account, - region - }; - - const missing = Object.keys(stack.missingContext).length ? stack.missingContext : undefined; - return { - name: stack.node.id, - environment, - missing, - template: stack.toCloudFormation(), - metadata: this.collectMetadata(stack), - dependsOn: noEmptyArray(stack.dependencies().map(s => s.node.id)), - }; + return res; } /** * Synthesizes multiple stacks + * @deprecated This method is going to be deprecated in a future version of the CDK */ public synthesizeStacks(stackNames: string[]): cxapi.SynthesizedStack[] { - this.node.prepareTree(); - this.prepared = true; - const ret: cxapi.SynthesizedStack[] = []; for (const stackName of stackNames) { ret.push(this.synthesizeStack(stackName)); @@ -112,138 +104,20 @@ export class App extends Root { return ret; } - /** - * Returns metadata for all constructs in the stack. - */ - public collectMetadata(stack: Stack) { - const output: { [id: string]: MetadataEntry[] } = { }; - - visit(stack); - - // add app-level metadata under "." - if (this.node.metadata.length > 0) { - output[PATH_SEP] = this.node.metadata; - } - - return output; - - function visit(node: IConstruct) { - if (node.node.metadata.length > 0) { - // Make the path absolute - output[PATH_SEP + node.node.path] = node.node.metadata.map(md => node.node.resolve(md) as MetadataEntry); - } - - for (const child of node.node.children) { - visit(child); - } + private loadContext(defaults: { [key: string]: string } = { }) { + // prime with defaults passed through constructor + for (const [ k, v ] of Object.entries(defaults)) { + this.node.setContext(k, v); } - } - private collectRuntimeInformation(): cxapi.AppRuntime { - const libraries: { [name: string]: string } = {}; - - for (const fileName of Object.keys(require.cache)) { - const pkg = findNpmPackage(fileName); - if (pkg && !pkg.private) { - libraries[pkg.name] = pkg.version; - } - } - - // include only libraries that are in the @aws-cdk npm scope - for (const name of Object.keys(libraries)) { - if (!name.startsWith('@aws-cdk/')) { - delete libraries[name]; - } - } - - // add jsii runtime version - libraries['jsii-runtime'] = getJsiiAgentVersion(); - - return { libraries }; - } - - private getStack(stackname: string) { - if (stackname == null) { - throw new Error('Stack name must be defined'); - } - - const stack = this.stacks[stackname]; - if (!stack) { - throw new Error(`Cannot find stack ${stackname}`); - } - return stack; - } - - private loadContext() { + // read from environment const contextJson = process.env[cxapi.CONTEXT_ENV]; - const context = !contextJson ? { } : JSON.parse(contextJson); - for (const key of Object.keys(context)) { - this.node.setContext(key, context[key]); - } - } -} - -/** - * Determines which NPM module a given loaded javascript file is from. - * - * The only infromation that is available locally is a list of Javascript files, - * and every source file is associated with a search path to resolve the further - * ``require`` calls made from there, which includes its own directory on disk, - * and parent directories - for example: - * - * [ '...repo/packages/aws-cdk-resources/lib/cfn/node_modules', - * '...repo/packages/aws-cdk-resources/lib/node_modules', - * '...repo/packages/aws-cdk-resources/node_modules', - * '...repo/packages/node_modules', - * // etc... - * ] - * - * We are looking for ``package.json`` that is anywhere in the tree, except it's - * in the parent directory, not in the ``node_modules`` directory. For this - * reason, we strip the ``/node_modules`` suffix off each path and use regular - * module resolution to obtain a reference to ``package.json``. - * - * @param fileName a javascript file name. - * @returns the NPM module infos (aka ``package.json`` contents), or - * ``undefined`` if the lookup was unsuccessful. - */ -function findNpmPackage(fileName: string): { name: string, version: string, private?: boolean } | undefined { - const mod = require.cache[fileName]; - const paths = mod.paths.map(stripNodeModules); - - try { - const packagePath = require.resolve('package.json', { paths }); - return require(packagePath); - } catch (e) { - return undefined; - } + const contextFromEnvironment = contextJson + ? JSON.parse(contextJson) + : { }; - /** - * @param s a path. - * @returns ``s`` with any terminating ``/node_modules`` - * (or ``\\node_modules``) stripped off.) - */ - function stripNodeModules(s: string): string { - if (s.endsWith('/node_modules') || s.endsWith('\\node_modules')) { - // /node_modules is 13 characters - return s.substr(0, s.length - 13); + for (const [ k, v ] of Object.entries(contextFromEnvironment)) { + this.node.setContext(k, v); } - return s; } } - -function getJsiiAgentVersion() { - let jsiiAgent = process.env.JSII_AGENT; - - // if JSII_AGENT is not specified, we will assume this is a node.js runtime - // and plug in our node.js version - if (!jsiiAgent) { - jsiiAgent = `node.js/${process.version}`; - } - - return jsiiAgent; -} - -function noEmptyArray(xs: T[]): T[] | undefined { - return xs.length > 0 ? xs : undefined; -} diff --git a/packages/@aws-cdk/cdk/lib/cloudformation/stack.ts b/packages/@aws-cdk/cdk/lib/cloudformation/stack.ts index 4f5e38e1f27b8..99f97ae63f069 100644 --- a/packages/@aws-cdk/cdk/lib/cloudformation/stack.ts +++ b/packages/@aws-cdk/cdk/lib/cloudformation/stack.ts @@ -1,9 +1,11 @@ import cxapi = require('@aws-cdk/cx-api'); import { App } from '../app'; -import { Construct, IConstruct } from '../core/construct'; +import { Construct, IConstruct, PATH_SEP } from '../core/construct'; import { Environment } from '../environment'; +import { ISynthesisSession } from '../synthesis'; import { CfnReference } from './cfn-tokens'; import { HashedAddressingScheme, IAddressingScheme, LogicalIDs } from './logical-id'; +import { Parameter } from './parameter'; export interface StackProps { /** @@ -87,6 +89,11 @@ export class Stack extends Construct { */ private readonly stackDependencies = new Set(); + /** + * Values set for parameters in cloud assembly. + */ + private readonly parameterValues: { [logicalId: string]: string } = { }; + /** * Creates a new stack. * @@ -108,6 +115,15 @@ export class Stack extends Construct { this.name = this.node.id; } + /** + * Returns the environment specification for this stack (aws://account/region). + */ + public get environment() { + const account = this.env.account || 'unknown-account'; + const region = this.env.region || 'unknown-region'; + return `aws://${account}/${region}`; + } + /** * Looks up a resource by path. * @@ -375,6 +391,15 @@ export class Stack extends Construct { return parseArn(arn, sepIfToken, hasName); } + /** + * Sets the value of a CloudFormation parameter. + * @param parameter The parameter to set the value for + * @param value The value, can use `${}` notation to reference other assembly block attributes. + */ + public setParameterValue(parameter: Parameter, value: string) { + this.parameterValues[parameter.logicalId] = value; + } + /** * Validate stack name * @@ -417,6 +442,43 @@ export class Stack extends Construct { } } + protected synthesize(session: ISynthesisSession): void { + const template = `${this.node.id}.template.json`; + + // write the CloudFormation template as a JSON file + session.store.writeJson(template, this.toCloudFormation()); + + const artifact: cxapi.Artifact = { + type: cxapi.ArtifactType.AwsCloudFormationStack, + environment: this.environment, + properties: { + templateFile: template, + } + }; + + if (Object.keys(this.parameterValues).length > 0) { + artifact.properties = artifact.properties || { }; + artifact.properties.parameters = this.node.resolve(this.parameterValues); + } + + const deps = this.dependencies().map(s => s.node.id); + if (deps.length > 0) { + artifact.dependencies = deps; + } + + const meta = this.collectMetadata(); + if (Object.keys(meta).length > 0) { + artifact.metadata = meta; + } + + if (this.missingContext && Object.keys(this.missingContext).length > 0) { + artifact.missing = this.missingContext; + } + + // add an artifact that represents this stack + session.addArtifact(this.node.id, artifact); + } + /** * Applied defaults to environment attributes. */ @@ -447,6 +509,30 @@ export class Stack extends Construct { } return false; } + + private collectMetadata() { + const output: { [id: string]: cxapi.MetadataEntry[] } = { }; + + visit(this); + + const app = this.parentApp(); + if (app && app.node.metadata.length > 0) { + output[PATH_SEP] = app.node.metadata; + } + + return output; + + function visit(node: IConstruct) { + if (node.node.metadata.length > 0) { + // Make the path absolute + output[PATH_SEP + node.node.path] = node.node.metadata.map(md => node.node.resolve(md) as cxapi.MetadataEntry); + } + + for (const child of node.node.children) { + visit(child); + } + } + } } function merge(template: any, part: any) { diff --git a/packages/@aws-cdk/cdk/lib/core/construct.ts b/packages/@aws-cdk/cdk/lib/core/construct.ts index 0a4a12cf7fef5..7892adc9b7f66 100644 --- a/packages/@aws-cdk/cdk/lib/core/construct.ts +++ b/packages/@aws-cdk/cdk/lib/core/construct.ts @@ -5,6 +5,7 @@ import { makeUniqueId } from '../util/uniqueid'; import { IDependable } from './dependency'; import { Token, unresolved } from './tokens'; import { resolve } from './tokens/resolve'; + export const PATH_SEP = '/'; /** @@ -188,17 +189,24 @@ export class ConstructNode { /** * Return this construct and all of its children in the given order */ - public findAll(order: ConstructOrder = ConstructOrder.DepthFirst): IConstruct[] { + public findAll(order: ConstructOrder = ConstructOrder.PreOrder): IConstruct[] { const ret = new Array(); - const queue: IConstruct[] = [this.host]; + visit(this.host); + return ret; - while (queue.length > 0) { - const next = order === ConstructOrder.BreadthFirst ? queue.splice(0, 1)[0] : queue.pop()!; - ret.push(next); - queue.push(...next.node.children); - } + function visit(node: IConstruct) { + if (order === ConstructOrder.PreOrder) { + ret.push(node); + } - return ret; + for (const child of node.node.children) { + visit(child); + } + + if (order === ConstructOrder.PostOrder) { + ret.push(node); + } + } } /** @@ -319,7 +327,7 @@ export class ConstructNode { * Run 'prepare()' on all constructs in the tree */ public prepareTree() { - const constructs = this.host.node.findAll(ConstructOrder.BreadthFirst); + const constructs = this.host.node.findAll(ConstructOrder.PreOrder); // Aspects are applied root to leaf for (const construct of constructs) { construct.node.invokeAspects(); @@ -626,9 +634,8 @@ export class Construct implements IConstruct { * understand the implications. */ protected prepare(): void { - // Intentionally left blank + return; } - } /** @@ -689,14 +696,14 @@ function createStackTrace(below: Function): string[] { */ export enum ConstructOrder { /** - * Breadth first + * Depth-first, pre-order */ - BreadthFirst, + PreOrder, /** - * Depth first + * Depth-first, post-order (leaf nodes first) */ - DepthFirst + PostOrder } /** diff --git a/packages/@aws-cdk/cdk/lib/index.ts b/packages/@aws-cdk/cdk/lib/index.ts index 49ffef7cdb7bf..6b4e11a8fb43a 100644 --- a/packages/@aws-cdk/cdk/lib/index.ts +++ b/packages/@aws-cdk/cdk/lib/index.ts @@ -32,3 +32,5 @@ export * from './context'; export * from './environment'; export * from './runtime'; + +export * from './synthesis'; \ No newline at end of file diff --git a/packages/@aws-cdk/cdk/lib/runtime-info.ts b/packages/@aws-cdk/cdk/lib/runtime-info.ts new file mode 100644 index 0000000000000..3bd6790634656 --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/runtime-info.ts @@ -0,0 +1,88 @@ +import cxapi = require('@aws-cdk/cx-api'); + +/** + * Returns a list of loaded modules and their versions. + */ +export function collectRuntimeInformation(): cxapi.AppRuntime { + const libraries: { [name: string]: string } = {}; + + for (const fileName of Object.keys(require.cache)) { + const pkg = findNpmPackage(fileName); + if (pkg && !pkg.private) { + libraries[pkg.name] = pkg.version; + } + } + + // include only libraries that are in the @aws-cdk npm scope + for (const name of Object.keys(libraries)) { + if (!name.startsWith('@aws-cdk/')) { + delete libraries[name]; + } + } + + // add jsii runtime version + libraries['jsii-runtime'] = getJsiiAgentVersion(); + + return { libraries }; +} + +/** + * Determines which NPM module a given loaded javascript file is from. + * + * The only infromation that is available locally is a list of Javascript files, + * and every source file is associated with a search path to resolve the further + * ``require`` calls made from there, which includes its own directory on disk, + * and parent directories - for example: + * + * [ '...repo/packages/aws-cdk-resources/lib/cfn/node_modules', + * '...repo/packages/aws-cdk-resources/lib/node_modules', + * '...repo/packages/aws-cdk-resources/node_modules', + * '...repo/packages/node_modules', + * // etc... + * ] + * + * We are looking for ``package.json`` that is anywhere in the tree, except it's + * in the parent directory, not in the ``node_modules`` directory. For this + * reason, we strip the ``/node_modules`` suffix off each path and use regular + * module resolution to obtain a reference to ``package.json``. + * + * @param fileName a javascript file name. + * @returns the NPM module infos (aka ``package.json`` contents), or + * ``undefined`` if the lookup was unsuccessful. + */ +function findNpmPackage(fileName: string): { name: string, version: string, private?: boolean } | undefined { + const mod = require.cache[fileName]; + const paths = mod.paths.map(stripNodeModules); + + try { + const packagePath = require.resolve('package.json', { paths }); + return require(packagePath); + } catch (e) { + return undefined; + } + + /** + * @param s a path. + * @returns ``s`` with any terminating ``/node_modules`` + * (or ``\\node_modules``) stripped off.) + */ + function stripNodeModules(s: string): string { + if (s.endsWith('/node_modules') || s.endsWith('\\node_modules')) { + // /node_modules is 13 characters + return s.substr(0, s.length - 13); + } + return s; + } +} + +function getJsiiAgentVersion() { + let jsiiAgent = process.env.JSII_AGENT; + + // if JSII_AGENT is not specified, we will assume this is a node.js runtime + // and plug in our node.js version + if (!jsiiAgent) { + jsiiAgent = `node.js/${process.version}`; + } + + return jsiiAgent; +} diff --git a/packages/@aws-cdk/cdk/lib/synthesis.ts b/packages/@aws-cdk/cdk/lib/synthesis.ts new file mode 100644 index 0000000000000..9b5d95d3ae1ae --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/synthesis.ts @@ -0,0 +1,372 @@ +import cxapi = require('@aws-cdk/cx-api'); +import fs = require('fs'); +import os = require('os'); +import path = require('path'); +import { collectRuntimeInformation } from './runtime-info'; + +export interface ISynthesizable { + synthesize(session: ISynthesisSession): void; +} + +export interface ISynthesisSession { + readonly store: ISessionStore; + readonly manifest: cxapi.AssemblyManifest; + addArtifact(id: string, droplet: cxapi.Artifact): void; + addBuildStep(id: string, step: cxapi.BuildStep): void; + tryGetArtifact(id: string): cxapi.Artifact | undefined; +} + +export interface SynthesisSessionOptions { + /** + * The file store used for this session. + */ + store: ISessionStore; + + /** + * Emit the legacy manifest (`cdk.out`) when the session is closed (alongside `manifest.json`). + * @default false + */ + legacyManifest?: boolean; + + /** + * Include runtime information (module versions) in manifest. + * @default true + */ + runtimeInformation?: boolean; +} + +export class SynthesisSession implements ISynthesisSession { + /** + * @returns true if `obj` implements `ISynthesizable`. + */ + public static isSynthesizable(obj: any): obj is ISynthesizable { + return 'synthesize' in obj; + } + + public readonly store: ISessionStore; + + private readonly artifacts: { [id: string]: cxapi.Artifact } = { }; + private readonly buildSteps: { [id: string]: cxapi.BuildStep } = { }; + private _manifest?: cxapi.AssemblyManifest; + private readonly legacyManifest: boolean; + private readonly runtimeInfo: boolean; + + constructor(options: SynthesisSessionOptions) { + this.store = options.store; + this.legacyManifest = options.legacyManifest !== undefined ? options.legacyManifest : false; + this.runtimeInfo = options.runtimeInformation !== undefined ? options.runtimeInformation : true; + } + + public get manifest() { + if (!this._manifest) { + throw new Error(`Cannot read assembly manifest before the session has been finalized`); + } + + return this._manifest; + } + + public addArtifact(id: string, artifact: cxapi.Artifact): void { + cxapi.validateArtifact(artifact); + this.artifacts[id] = artifact; + } + + public tryGetArtifact(id: string): cxapi.Artifact | undefined { + return this.artifacts[id]; + } + + public addBuildStep(id: string, step: cxapi.BuildStep) { + if (id in this.buildSteps) { + throw new Error(`Build step ${id} already exists`); + } + this.buildSteps[id] = step; + } + + public close(): cxapi.AssemblyManifest { + const manifest: cxapi.AssemblyManifest = this._manifest = { + version: cxapi.PROTO_RESPONSE_VERSION, + artifacts: this.artifacts, + }; + + if (this.runtimeInfo) { + manifest.runtime = collectRuntimeInformation(); + } + + this.store.writeFile(cxapi.MANIFEST_FILE, JSON.stringify(manifest, undefined, 2)); + + // write build manifest if we have build steps + if (Object.keys(this.buildSteps).length > 0) { + const buildManifest: cxapi.BuildManifest = { + steps: this.buildSteps + }; + + this.store.writeFile(cxapi.BUILD_FILE, JSON.stringify(buildManifest, undefined, 2)); + } + + if (this.legacyManifest) { + const legacy: cxapi.SynthesizeResponse = { + ...manifest, + stacks: renderLegacyStacks(this.artifacts, this.store) + }; + + // render the legacy manifest (cdk.out) which also contains a "stacks" attribute with all the rendered stacks. + this.store.writeFile(cxapi.OUTFILE_NAME, JSON.stringify(legacy, undefined, 2)); + } + + return manifest; + } +} + +export interface ISessionStore { + /** + * Creates a directory and returns it's full path. + * @param directoryName The name of the directory to create. + * @throws if a directory by that name already exists in the session or if the session has already been finalized. + */ + mkdir(directoryName: string): string; + + /** + * Returns the list of files in a directory. + * @param directoryName The name of the artifact + * @throws if there is no directory artifact under this name + */ + readdir(directoryName: string): string[]; + + /** + * Writes a file into the store. + * @param artifactName The name of the file. + * @param data The contents of the file. + */ + writeFile(artifactName: string, data: any): void; + + /** + * Writes a formatted JSON output file to the store + * @param artifactName the name of the artifact + * @param json the JSON object + */ + writeJson(artifactName: string, json: any): void; + + /** + * Reads a file from the store. + * @param fileName The name of the file. + * @throws if the file is not found + */ + readFile(fileName: string): any; + + /** + * Reads a JSON object from the store. + */ + readJson(fileName: string): any; + + /** + * @returns true if the file `fileName` exists in the store. + * @param name The name of the file or directory to look up. + */ + exists(name: string): boolean; + + /** + * List all top-level files that were emitted to the store. + */ + list(): string[]; + + /** + * Do not allow further writes into the store. + */ + finalize(): void; +} + +export interface SynthesisSessionOptions { + /** + * Where to store the + */ + store: ISessionStore; +} + +export interface FileSystemStoreOptions { + /** + * The output directory for synthesis artifacts + */ + outdir: string; +} + +/** + * Can be used to prepare and emit synthesis artifacts into an output directory. + */ +export class FileSystemStore implements ISessionStore { + private readonly outdir: string; + private locked = false; + + constructor(options: FileSystemStoreOptions) { + this.outdir = options.outdir; + return; + } + + public writeFile(fileName: string, data: any) { + this.canWrite(fileName); + + const p = this.pathForArtifact(fileName); + fs.writeFileSync(p, data); + } + + public writeJson(fileName: string, json: any) { + this.writeFile(fileName, JSON.stringify(json, undefined, 2)); + } + + public readFile(fileName: string): any { + const p = this.pathForArtifact(fileName); + if (!fs.existsSync(p)) { + throw new Error(`File not found: ${p}`); + } + + return fs.readFileSync(p); + } + + public readJson(fileName: string): any { + return JSON.parse(this.readFile(fileName).toString()); + } + + public exists(name: string): boolean { + const p = this.pathForArtifact(name); + return fs.existsSync(p); + } + + public mkdir(directoryName: string): string { + this.canWrite(directoryName); + const p = this.pathForArtifact(directoryName); + fs.mkdirSync(p); + return p; + } + + public readdir(directoryName: string): string[] { + if (!this.exists(directoryName)) { + throw new Error(`${directoryName} not found`); + } + + const p = this.pathForArtifact(directoryName); + return fs.readdirSync(p); + } + + public list(): string[] { + return fs.readdirSync(this.outdir).sort(); + } + + public finalize() { + this.locked = true; + } + + private pathForArtifact(id: string) { + return path.join(this.outdir, id); + } + + private canWrite(artifactName: string) { + if (this.exists(artifactName)) { + throw new Error(`An artifact named ${artifactName} was already written to this session`); + } + if (this.locked) { + throw new Error('Session has already been finalized'); + } + } +} + +export class InMemoryStore implements ISessionStore { + private files: { [fileName: string]: any } = { }; + private dirs: { [dirName: string]: string } = { }; // value is path to a temporary directory + + private locked = false; + + public writeFile(fileName: string, data: any): void { + this.canWrite(fileName); + this.files[fileName] = data; + } + + public writeJson(fileName: string, json: any): void { + this.writeFile(fileName, JSON.stringify(json, undefined, 2)); + } + + public readFile(fileName: string) { + if (!(fileName in this.files)) { + throw new Error(`${fileName} not found`); + } + return this.files[fileName]; + } + + public readJson(fileName: string): any { + return JSON.parse(this.readFile(fileName).toString()); + } + + public exists(name: string) { + return name in this.files || name in this.dirs; + } + + public mkdir(directoryName: string): string { + this.canWrite(directoryName); + + const p = fs.mkdtempSync(path.join(os.tmpdir(), directoryName)); + this.dirs[directoryName] = p; + return p; + } + + public readdir(directoryName: string): string[] { + if (!this.exists(directoryName)) { + throw new Error(`${directoryName} not found`); + } + + const p = this.dirs[directoryName]; + return fs.readdirSync(p); + } + + public list(): string[] { + return [ ...Object.keys(this.files), ...Object.keys(this.dirs) ].sort(); + } + + public finalize() { + this.locked = true; + } + + private canWrite(artifactName: string) { + if (this.exists(artifactName)) { + throw new Error(`An artifact named ${artifactName} was already written to this session`); + } + if (this.locked) { + throw new Error('Session has already been finalized'); + } + } +} + +function renderLegacyStacks(artifacts: { [id: string]: cxapi.Artifact }, store: ISessionStore) { + // special case for backwards compat. build a list of stacks for the manifest + const stacks = new Array(); + + for (const [ id, artifact ] of Object.entries(artifacts)) { + if (artifact.type === cxapi.ArtifactType.AwsCloudFormationStack) { + const templateFile = artifact.properties && artifact.properties.templateFile; + if (!templateFile) { + throw new Error(`Invalid cloudformation artifact. Missing "template" property`); + } + const template = store.readJson(templateFile); + + const match = cxapi.AWS_ENV_REGEX.exec(artifact.environment); + if (!match) { + throw new Error(`"environment" must match regex: ${cxapi.AWS_ENV_REGEX}`); + } + + const synthStack: cxapi.SynthesizedStack = { + name: id, + environment: { name: artifact.environment.substr('aws://'.length), account: match[1], region: match[2] }, + template, + metadata: artifact.metadata || {}, + }; + + if (artifact.dependencies && artifact.dependencies.length > 0) { + synthStack.dependsOn = artifact.dependencies; + } + + if (artifact.missing) { + synthStack.missing = artifact.missing; + } + + stacks.push(synthStack); + } + } + + return stacks; +} \ No newline at end of file diff --git a/packages/@aws-cdk/cdk/package-lock.json b/packages/@aws-cdk/cdk/package-lock.json index 31e02d65bc497..c7090ffd53e6a 100644 --- a/packages/@aws-cdk/cdk/package-lock.json +++ b/packages/@aws-cdk/cdk/package-lock.json @@ -1,6 +1,6 @@ { "name": "@aws-cdk/cdk", - "version": "0.24.1", + "version": "0.25.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -11,70 +11,21 @@ "dev": true }, "@types/lodash": { - "version": "4.14.120", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.120.tgz", - "integrity": "sha512-jQ21kQ120mo+IrDs1nFNVm/AsdFxIx2+vZ347DbogHJPd/JzKNMOqU6HCYin1W6v8l5R9XSO2/e9cxmn7HAnVw==", + "version": "4.14.121", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.121.tgz", + "integrity": "sha512-ORj7IBWj13iYufXt/VXrCNMbUuCTJfhzme5kx9U/UtcIPdJYuvPDUAlHlbNhz/8lKCLy9XGIZnGrqXOtQbPGoQ==", "dev": true }, - "cli-color": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-0.1.7.tgz", - "integrity": "sha1-rcMgD6RxzCEbDaf1ZrcemLnWc0c=", - "requires": { - "es5-ext": "0.8.x" - } - }, - "difflib": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/difflib/-/difflib-0.2.4.tgz", - "integrity": "sha1-teMDYabbAjF21WKJLbhZQKcY9H4=", - "requires": { - "heap": ">= 0.2.0" - } - }, - "dreamopt": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/dreamopt/-/dreamopt-0.6.0.tgz", - "integrity": "sha1-2BPM2sjTnYrVJndVFKE92mZNa0s=", - "requires": { - "wordwrap": ">=0.0.2" - } - }, - "es5-ext": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.8.2.tgz", - "integrity": "sha1-q6jZ4ZQ6iVrJaDemKjmz9V7NlKs=" - }, "fast-check": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-1.10.0.tgz", - "integrity": "sha512-6MoIj+RsnMWNX1cB5IY79Jlt6FPyQ0b7ur+sbJVaQ8F+xWz2E0vyS/HZzhDwrq5ZFxQF95HZdDaRLwFTiAy9Bg==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-1.11.0.tgz", + "integrity": "sha512-ZXaJLSga+LqmV4Lqs/ye5+hce1uyahKf772SqUVrcMq84MjWIbGuy9GRQnl6u/vlDak/d2SF6g4WGyi0zXxo1w==", "dev": true, "requires": { "lorem-ipsum": "~1.0.6", "pure-rand": "^1.6.2" } }, - "heap": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.6.tgz", - "integrity": "sha1-CH4fELBGky/IWU3Z5tN4r8nR5aw=" - }, - "js-base64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.5.1.tgz", - "integrity": "sha512-M7kLczedRMYX4L8Mdh4MzyAMM9O5osx+4FcOQuTvr3A9F2D9S5JXheN0ewNbrvK2UatkTRhL5ejGmGSjNMiZuw==" - }, - "json-diff": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/json-diff/-/json-diff-0.3.1.tgz", - "integrity": "sha1-bbw64tJeB1p/1xvNmHRFhmb7aBs=", - "requires": { - "cli-color": "~0.1.6", - "difflib": "~0.2.1", - "dreamopt": "~0.6.0" - } - }, "lodash": { "version": "4.17.11", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", @@ -101,11 +52,6 @@ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-1.6.2.tgz", "integrity": "sha512-HNwHOH63m7kCxe0kWEe5jSLwJiL2N83RUUN8POniFuZS+OsbFcMWlvXgxIU2nwKy2zYG2bQan40WBNK4biYPRg==", "dev": true - }, - "wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=" } } } diff --git a/packages/@aws-cdk/cdk/package.json b/packages/@aws-cdk/cdk/package.json index 5f3edee56a48b..707ad167eb8f0 100644 --- a/packages/@aws-cdk/cdk/package.json +++ b/packages/@aws-cdk/cdk/package.json @@ -70,14 +70,8 @@ "pkglint": "^0.25.0" }, "dependencies": { - "@aws-cdk/cx-api": "^0.25.0", - "js-base64": "^2.4.5", - "json-diff": "^0.3.1" + "@aws-cdk/cx-api": "^0.25.0" }, - "bundledDependencies": [ - "json-diff", - "js-base64" - ], "homepage": "https://github.com/awslabs/aws-cdk", "peerDependencies": { "@aws-cdk/cx-api": "^0.25.0" @@ -85,4 +79,4 @@ "engines": { "node": ">= 8.10.0" } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/cdk/test/core/test.construct.ts b/packages/@aws-cdk/cdk/test/core/test.construct.ts index 851d179a53200..a711efa001360 100644 --- a/packages/@aws-cdk/cdk/test/core/test.construct.ts +++ b/packages/@aws-cdk/cdk/test/core/test.construct.ts @@ -1,6 +1,6 @@ import cxapi = require('@aws-cdk/cx-api'); import { Test } from 'nodeunit'; -import { ArnComponents, Construct, Root, Stack, Token } from '../../lib'; +import { ArnComponents, Construct, ConstructOrder, Root, Stack, Token } from '../../lib'; // tslint:disable:variable-name // tslint:disable:max-line-length @@ -446,6 +446,21 @@ export = { new Construct(c1a, 'c1aZ'); new Construct(c1b, 'c1bZ'); + test.done(); + }, + + 'findAll returns a list of all children in either DFS or BFS'(test: Test) { + // GIVEN + const c1 = new Construct(undefined as any, '1'); + const c2 = new Construct(c1, '2'); + new Construct(c1, '3'); + new Construct(c2, '4'); + new Construct(c2, '5'); + + // THEN + test.deepEqual(c1.node.findAll().map(x => x.node.id), c1.node.findAll(ConstructOrder.PreOrder).map(x => x.node.id)); // default is PreOrder + test.deepEqual(c1.node.findAll(ConstructOrder.PreOrder).map(x => x.node.id), [ '1', '2', '4', '5', '3' ]); + test.deepEqual(c1.node.findAll(ConstructOrder.PostOrder).map(x => x.node.id), [ '4', '5', '2', '3', '1' ]); test.done(); } }; diff --git a/packages/@aws-cdk/cdk/test/test.app.ts b/packages/@aws-cdk/cdk/test/test.app.ts index 40c5244ef8462..59916f49c83d3 100644 --- a/packages/@aws-cdk/cdk/test/test.app.ts +++ b/packages/@aws-cdk/cdk/test/test.app.ts @@ -1,32 +1,17 @@ import cxapi = require('@aws-cdk/cx-api'); -import fs = require('fs'); import { Test } from 'nodeunit'; -import os = require('os'); -import path = require('path'); import { Construct, Resource, Stack, StackProps } from '../lib'; import { App } from '../lib/app'; -function withApp(context: { [key: string]: any } | undefined, block: (app: App) => void) { - const outdir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-app-test')); - process.env[cxapi.OUTDIR_ENV] = outdir; - - if (context) { - process.env[cxapi.CONTEXT_ENV] = JSON.stringify(context); - } else { - delete process.env[cxapi.CONTEXT_ENV]; - } - - const app = new App(); +function withApp(context: { [key: string]: any } | undefined, block: (app: App) => void): cxapi.SynthesizeResponse { + const app = new App(context); block(app); - app.run(); + const session = app.run(); - const outfile = path.join(outdir, cxapi.OUTFILE_NAME); - const response = JSON.parse(fs.readFileSync(outfile).toString()); - fs.unlinkSync(outfile); - fs.rmdirSync(outdir); - return response; + // return the legacy manifest + return session.store.readJson(cxapi.OUTFILE_NAME); } function synth(context?: { [key: string]: any }): cxapi.SynthesizeResponse { @@ -69,6 +54,7 @@ export = { // clean up metadata so assertion will be sane response.stacks.forEach(s => delete s.metadata); delete response.runtime; + delete response.artifacts; test.deepEqual(response, { version: '0.19.0', @@ -100,13 +86,7 @@ export = { const stack = new Stack(prog, 'MyStack'); new Resource(stack, 'MyResource', { type: 'MyResourceType' }); - let throws; - try { - prog.synthesizeStacks(['foo']); - } catch (e) { - throws = e.message; - } - test.ok(throws.indexOf('Cannot find stack foo') !== -1); + test.throws(() => prog.synthesizeStacks(['foo']), /foo/); test.deepEqual(prog.synthesizeStack('MyStack').template, { Resources: { MyResource: { Type: 'MyResourceType' } } }); @@ -212,7 +192,7 @@ export = { test.throws(() => { app.synthesizeStacks(['Parent']); - }, /Stack validation failed with the following errors/); + }, /Validation failed with the following errors/); test.done(); }, @@ -287,7 +267,7 @@ export = { new Resource(stack, 'MyResource', { type: 'Resource::Type' }); }); - const libs = response.runtime.libraries; + const libs = (response.runtime && response.runtime.libraries) || { }; const version = require('../package.json').version; test.deepEqual(libs['@aws-cdk/cdk'], version); @@ -304,7 +284,7 @@ export = { new Resource(stack, 'MyResource', { type: 'Resource::Type' }); }); - const libs = response.runtime.libraries; + const libs = (response.runtime && response.runtime.libraries) || { }; test.deepEqual(libs['jsii-runtime'], `Java/1.2.3.4`); delete process.env.JSII_AGENT; @@ -317,7 +297,7 @@ export = { new Resource(stack, 'MyResource', { type: 'Resource::Type' }); }); - const libs = response.runtime.libraries; + const libs = (response.runtime && response.runtime.libraries) || { }; const version = require('../package.json').version; test.deepEqual(libs, { diff --git a/packages/@aws-cdk/cdk/test/test.synthesis.ts b/packages/@aws-cdk/cdk/test/test.synthesis.ts new file mode 100644 index 0000000000000..108e668da5668 --- /dev/null +++ b/packages/@aws-cdk/cdk/test/test.synthesis.ts @@ -0,0 +1,337 @@ +import cxapi = require('@aws-cdk/cx-api'); +import fs = require('fs'); +import { Test } from 'nodeunit'; +import os = require('os'); +import path = require('path'); +import cdk = require('../lib'); +import { FileSystemStore, InMemoryStore, SynthesisSession } from '../lib'; + +const storeTestMatrix: any = {}; + +function createModernApp() { + return new cdk.App({ + [cxapi.DISABLE_LEGACY_MANIFEST_CONTEXT]: 'true', + [cxapi.DISABLE_VERSION_REPORTING]: 'true', // for test reproducibility + }); +} + +export = { + 'synthesis with an empty app'(test: Test) { + // GIVEN + const app = createModernApp(); + + // WHEN + const session = app.run(); + + // THEN + test.same(app.run(), session); // same session if we run() again + test.deepEqual(session.store.list(), [ 'manifest.json' ]); + test.deepEqual(session.store.readJson('manifest.json').artifacts, {}); + test.done(); + }, + + 'single empty stack'(test: Test) { + // GIVEN + const app = createModernApp(); + new cdk.Stack(app, 'one-stack'); + + // WHEN + const session = app.run(); + + // THEN + test.deepEqual(session.store.list(), [ + 'manifest.json', + 'one-stack.template.json' + ]); + test.done(); + }, + + 'some random construct implements "synthesize"'(test: Test) { + // GIVEN + const app = createModernApp(); + const stack = new cdk.Stack(app, 'one-stack'); + + class MyConstruct extends cdk.Construct implements cdk.ISynthesizable { + public synthesize(s: cdk.ISynthesisSession) { + s.store.writeJson('foo.json', { bar: 123 }); + s.addArtifact('my-random-construct', { + type: cxapi.ArtifactType.AwsCloudFormationStack, + environment: 'aws://12345/bar', + properties: { + templateFile: 'file://boom' + } + }); + } + } + + new MyConstruct(stack, 'MyConstruct'); + + // WHEN + const session = app.run(); + + // THEN + test.deepEqual(session.store.list(), [ + 'foo.json', + 'manifest.json', + 'one-stack.template.json' + ]); + test.deepEqual(session.store.readJson('foo.json'), { bar: 123 }); + test.deepEqual(session.manifest, { + version: '0.19.0', + artifacts: { + 'my-random-construct': { + type: 'aws:cloudformation:stack', + environment: 'aws://12345/bar', + properties: { templateFile: 'file://boom' } + }, + 'one-stack': { + type: 'aws:cloudformation:stack', + environment: 'aws://unknown-account/unknown-region', + properties: { templateFile: 'one-stack.template.json' } + } + }, + }); + test.done(); + }, + + 'backwards compatibility: cdk.out contains all synthesized stacks'(test: Test) { + // GIVEN + const app = new cdk.App(); + const stack1 = new cdk.Stack(app, 'stack1'); + new cdk.Resource(stack1, 'Resource1', { type: 'AWS::CDK::Resource' }); + new cdk.Resource(stack1, 'Resource2', { type: 'AWS::CDK::Resource' }); + const stack2 = new cdk.Stack(app, 'stack2'); + new cdk.Resource(stack2, 'ResourceA', { type: 'AWS::CDK::Resource' }); + + // WHEN + const session = app.run(); + const legacy: cxapi.SynthesizeResponse = session.store.readJson(cxapi.OUTFILE_NAME); + + // THEN + const t1 = legacy.stacks.find(s => s.name === 'stack1')!.template; + const t2 = legacy.stacks.find(s => s.name === 'stack2')!.template; + + test.deepEqual(t1, { + Resources: { + Resource1: { Type: 'AWS::CDK::Resource' }, + Resource2: { Type: 'AWS::CDK::Resource' } + } + }); + test.deepEqual(t2, { + Resources: { + ResourceA: { Type: 'AWS::CDK::Resource' } + } + }); + test.done(); + }, + + 'store': storeTestMatrix +}; + +// +// all these tests will be executed for each type of store +// +const storeTests = { + 'writeFile()/readFile()'(test: Test, store: cdk.ISessionStore) { + // WHEN + store.writeFile('bla.txt', 'hello'); + store.writeFile('hey.txt', '1234'); + + // THEN + test.deepEqual(store.readFile('bla.txt').toString(), 'hello'); + test.deepEqual(store.readFile('hey.txt').toString(), '1234'); + test.throws(() => store.writeFile('bla.txt', 'override is forbidden')); + + // WHEN + store.finalize(); + + // THEN + test.throws(() => store.writeFile('another.txt', 'locked!')); + test.done(); + }, + + 'exists() for files'(test: Test, store: cdk.ISessionStore) { + // WHEN + store.writeFile('A.txt', 'aaa'); + + // THEN + test.ok(store.exists('A.txt')); + test.ok(!store.exists('B.txt')); + test.done(); + }, + + 'mkdir'(test: Test, store: cdk.ISessionStore) { + // WHEN + const dir1 = store.mkdir('dir1'); + const dir2 = store.mkdir('dir2'); + + // THEN + test.ok(fs.statSync(dir1).isDirectory()); + test.ok(fs.statSync(dir2).isDirectory()); + test.throws(() => store.mkdir('dir1')); + + // WHEN + store.finalize(); + test.throws(() => store.mkdir('dir3')); + test.done(); + }, + + 'list'(test: Test, store: cdk.ISessionStore) { + // WHEN + store.mkdir('dir1'); + store.writeFile('file1.txt', 'boom1'); + + // THEN + test.deepEqual(store.list(), ['dir1', 'file1.txt']); + test.done(); + }, + + 'SynthesisSession'(test: Test, store: cdk.ISessionStore) { + // GIVEN + const session = new SynthesisSession({ store }); + const templateFile = 'foo.template.json'; + + // WHEN + session.addArtifact('my-first-artifact', { + type: cxapi.ArtifactType.AwsCloudFormationStack, + environment: 'aws://1222344/us-east-1', + dependencies: ['a', 'b'], + metadata: { + foo: { bar: 123 } + }, + properties: { + templateFile, + parameters: { + prop1: '1234', + prop2: '555' + } + }, + missing: { + foo: { + provider: 'context-provider', + props: { + a: 'A', + b: 2 + } + } + } + }); + + session.addArtifact('minimal-artifact', { + type: cxapi.ArtifactType.AwsCloudFormationStack, + environment: 'aws://111/helo-world', + properties: { + templateFile + } + }); + + session.store.writeJson(templateFile, { + Resources: { + MyTopic: { + Type: 'AWS::S3::Topic' + } + } + }); + + session.close(); + + const manifest = session.store.readJson(cxapi.MANIFEST_FILE); + + // THEN + delete manifest.runtime; // deterministic tests + + // verify the manifest looks right + test.deepEqual(manifest, { + version: cxapi.PROTO_RESPONSE_VERSION, + artifacts: { + 'my-first-artifact': { + type: 'aws:cloudformation:stack', + environment: 'aws://1222344/us-east-1', + dependencies: ['a', 'b'], + metadata: { foo: { bar: 123 } }, + properties: { + templateFile: 'foo.template.json', + parameters: { + prop1: '1234', + prop2: '555' + }, + }, + missing: { + foo: { provider: 'context-provider', props: { a: 'A', b: 2 } } + } + }, + 'minimal-artifact': { + type: 'aws:cloudformation:stack', + environment: 'aws://111/helo-world', + properties: { templateFile: 'foo.template.json' } + } + } + }); + + // verify we have a template file + test.deepEqual(session.store.readJson(templateFile), { + Resources: { + MyTopic: { + Type: 'AWS::S3::Topic' + } + } + }); + + test.done(); + }, + + 'stack.setParameterValue can be used to assign parameters'(test: Test) { + // GIVEN + const app = createModernApp(); + const stack = new cdk.Stack(app, 'my-stack'); + const param = new cdk.Parameter(stack, 'MyParam', { type: 'string' }); + + // WHEN + stack.setParameterValue(param, 'Foo'); + + // THEN + const session = app.run(); + const props = (session.manifest.artifacts && session.manifest.artifacts['my-stack'].properties) || {}; + test.deepEqual(props.parameters, { + MyParam: 'Foo' + }); + test.done(); + }, + + 'addBuildStep can be used to produce build.json'(test: Test) { + // GIVEN + const app = createModernApp(); + + // WHEN + class BuildMe extends cdk.Construct implements cdk.ISynthesizable { + public synthesize(s: cdk.ISynthesisSession) { + s.addBuildStep('step_id', { + type: 'build-step-type', + parameters: { + boom: 123 + } + }); + } + } + + new BuildMe(app, 'hey'); + + // THEN + const session = app.run(); + test.deepEqual(session.store.list(), [ 'build.json', 'manifest.json' ]); + test.deepEqual(session.store.readJson('build.json'), { + steps: { + step_id: { type: 'build-step-type', parameters: { boom: 123 } } + } + }); + test.done(); + } +}; + +for (const [name, fn] of Object.entries(storeTests)) { + const outdir = fs.mkdtempSync(path.join(os.tmpdir(), 'synthesis-tests')); + const fsStore = new FileSystemStore({ outdir }); + const memoryStore = new InMemoryStore(); + storeTestMatrix[`FileSystemStore - ${name}`] = (test: Test) => fn(test, fsStore); + storeTestMatrix[`InMemoryStore - ${name}`] = (test: Test) => fn(test, memoryStore); +} diff --git a/packages/@aws-cdk/cx-api/lib/artifacts.ts b/packages/@aws-cdk/cx-api/lib/artifacts.ts new file mode 100644 index 0000000000000..133027dfe04b1 --- /dev/null +++ b/packages/@aws-cdk/cx-api/lib/artifacts.ts @@ -0,0 +1,22 @@ +export const AWS_ENV_REGEX = /aws\:\/\/([0-9]+|unknown-account)\/([a-z\-0-9]+)/; + +export enum ArtifactType { + AwsCloudFormationStack = 'aws:cloudformation:stack', + AwsEcrDockerImage = 'aws:ecr:image', + AwsS3Object = 'aws:s3:object' +} + +export interface Artifact { + type: ArtifactType; + environment: string; // format: aws://account/region + metadata?: { [path: string]: any }; + dependencies?: string[]; + missing?: { [key: string]: any }; + properties?: { [name: string]: any }; +} + +export function validateArtifact(artifcat: Artifact) { + if (!AWS_ENV_REGEX.test(artifcat.environment)) { + throw new Error(`Artifact "environment" must conform to ${AWS_ENV_REGEX}: ${artifcat.environment}`); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/lib/build.ts b/packages/@aws-cdk/cx-api/lib/build.ts new file mode 100644 index 0000000000000..c4363f67d5f50 --- /dev/null +++ b/packages/@aws-cdk/cx-api/lib/build.ts @@ -0,0 +1,16 @@ +export interface BuildStep { + type: string; + depends?: string[]; + parameters: { + [key: string]: any + }; +} + +export interface BuildManifest { + steps: { [id: string]: BuildStep }; +} + +export enum BuildStepType { + CopyFile = 'copy-file', + ZipDirectory = 'zip-directory' +} diff --git a/packages/@aws-cdk/cx-api/lib/cxapi.ts b/packages/@aws-cdk/cx-api/lib/cxapi.ts index b7e186194433a..8bbbdcf9340bb 100644 --- a/packages/@aws-cdk/cx-api/lib/cxapi.ts +++ b/packages/@aws-cdk/cx-api/lib/cxapi.ts @@ -2,6 +2,7 @@ * File with definitions for the interface between the Cloud Executable and the CDK toolkit. */ +import { Artifact } from './artifacts'; import { Environment } from './environment'; /** @@ -22,7 +23,16 @@ import { Environment } from './environment'; */ export const PROTO_RESPONSE_VERSION = '0.19.0'; -export const OUTFILE_NAME = 'cdk.out'; +/** + * The name of the root manifest file of the assembly. + */ +export const MANIFEST_FILE = 'manifest.json'; + +/** + * The name of the root file with build instructions. + */ +export const BUILD_FILE = 'build.json'; + export const OUTDIR_ENV = 'CDK_OUTDIR'; export const CONTEXT_ENV = 'CDK_CONTEXT_JSON'; @@ -38,15 +48,30 @@ export interface MissingContext { }; } -export interface SynthesizeResponse { +export interface AssemblyManifest { /** * Protocol version */ version: string; - stacks: SynthesizedStack[]; + + /** + * The set of artifacts in this assembly. + */ + artifacts?: { [id: string]: Artifact }; + + /** + * Runtime information. + */ runtime?: AppRuntime; } +/** + * @deprecated use `AssemblyManifest` + */ +export interface SynthesizeResponse extends AssemblyManifest { + stacks: SynthesizedStack[]; +} + /** * A complete synthesized stack */ @@ -134,6 +159,19 @@ export const PATH_METADATA_KEY = 'aws:cdk:path'; */ export const PATH_METADATA_ENABLE_CONTEXT = 'aws:cdk:enable-path-metadata'; +/** + * Disables the emission of `cdk.out` + */ +export const DISABLE_LEGACY_MANIFEST_CONTEXT = 'aws:cdk:disable-legacy-manifest'; + +/** + * The name of the pre 0.25.0 manifest file. Will only be emitted if + * aws:cdk:disable-legacy-manifest is not defined. + * + * @deprecated Use `MANIFEST_FILE` + */ +export const OUTFILE_NAME = 'cdk.out'; + /** * Disable the collection and reporting of version information. */ diff --git a/packages/@aws-cdk/cx-api/lib/index.ts b/packages/@aws-cdk/cx-api/lib/index.ts index 8be9dc02a8bd7..b3ebfaa8cf77a 100644 --- a/packages/@aws-cdk/cx-api/lib/index.ts +++ b/packages/@aws-cdk/cx-api/lib/index.ts @@ -5,3 +5,5 @@ export * from './context/vpc'; export * from './context/ssm-parameter'; export * from './context/availability-zones'; export * from './metadata/assets'; +export * from './artifacts'; +export * from './build';