From 2b567a750caafc9cfe995b7289bb12ca8d8e2a97 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Fri, 15 Mar 2019 14:53:32 +0100 Subject: [PATCH 1/2] fix(toolkit): 'cdk deploy' updates to Outputs only If only stack Outputs are changed, CloudFormation generates a ChangeSet that is executable but has 0 changes. Before, we looked at the amount of changes to say there was nothing to do, but now we look at the actual change set status to determine whether it's an empty change set or not. The effect is that we can now deploy updates even if only Outputs changed. This becomes very important when the only thing changed to a stack is an Output got added because a cross-stack reference was taken by a downstream stack. Fixes #778. --- packages/aws-cdk/lib/api/deploy-stack.ts | 9 +- .../aws-cdk/lib/api/util/cloudformation.ts | 35 +++- packages/aws-cdk/package-lock.json | 190 +++++++++++++++--- packages/aws-cdk/package.json | 2 + .../aws-cdk/test/api/test.deploy-stack.ts | 55 +++++ packages/aws-cdk/test/util/fake-sdk.ts | 100 +++++++++ 6 files changed, 355 insertions(+), 36 deletions(-) create mode 100644 packages/aws-cdk/test/api/test.deploy-stack.ts create mode 100644 packages/aws-cdk/test/util/fake-sdk.ts diff --git a/packages/aws-cdk/lib/api/deploy-stack.ts b/packages/aws-cdk/lib/api/deploy-stack.ts index d678505444d77..d7c8f96da323a 100644 --- a/packages/aws-cdk/lib/api/deploy-stack.ts +++ b/packages/aws-cdk/lib/api/deploy-stack.ts @@ -7,7 +7,7 @@ import { debug, error, print } from '../logging'; import { toYAML } from '../serialize'; import { Mode } from './aws-auth/credentials'; import { ToolkitInfo } from './toolkit-info'; -import { describeStack, stackExists, stackFailedCreating, waitForChangeSet, waitForStack } from './util/cloudformation'; +import { changeSetHasNoChanges, describeStack, stackExists, stackFailedCreating, waitForChangeSet, waitForStack } from './util/cloudformation'; import { StackActivityMonitor } from './util/cloudformation/stack-activity-monitor'; import { StackStatus } from './util/cloudformation/stack-status'; import { SDK } from './util/sdk'; @@ -77,8 +77,9 @@ export async function deployStack(options: DeployStackOptions): Promise(valueProvider: () => Promise, ti /** * Waits for a ChangeSet to be available for triggering a StackUpdate. * + * Will return a changeset that is either ready to be executed or has no changes. + * Will throw in other cases. + * * @param cfn a CloudFormation client * @param stackName the name of the Stack that the ChangeSet belongs to * @param changeSetName the name of the ChangeSet @@ -93,25 +96,43 @@ async function waitFor(valueProvider: () => Promise, ti * @returns the CloudFormation description of the ChangeSet */ // tslint:disable-next-line:max-line-length -export async function waitForChangeSet(cfn: CloudFormation, stackName: string, changeSetName: string): Promise { +export async function waitForChangeSet(cfn: CloudFormation, stackName: string, changeSetName: string): Promise { debug('Waiting for changeset %s on stack %s to finish creating...', changeSetName, stackName); - return waitFor(async () => { + const ret = await waitFor(async () => { const description = await describeChangeSet(cfn, stackName, changeSetName); // The following doesn't use a switch because tsc will not allow fall-through, UNLESS it is allows // EVERYWHERE that uses this library directly or indirectly, which is undesirable. if (description.Status === 'CREATE_PENDING' || description.Status === 'CREATE_IN_PROGRESS') { debug('Changeset %s on stack %s is still creating', changeSetName, stackName); return undefined; - } else if (description.Status === 'CREATE_COMPLETE') { + } + + if (description.Status === 'CREATE_COMPLETE' || changeSetHasNoChanges(description)) { return description; - } else if (description.Status === 'FAILED') { - if (description.StatusReason && description.StatusReason.startsWith('The submitted information didn\'t contain changes.')) { - return description; - } } + // tslint:disable-next-line:max-line-length throw new Error(`Failed to create ChangeSet ${changeSetName} on ${stackName}: ${description.Status || 'NO_STATUS'}, ${description.StatusReason || 'no reason provided'}`); }); + + if (!ret) { + throw new Error('Change set took too long to be created; aborting'); + } + + return ret; +} + +/** + * Return true if the given change set has no changes + * + * This must be determined from the status, not the 'Changes' array on the + * object; the latter can be empty because no resources were changed, but if + * there are changes to Outputs, the change set can still be executed. + */ +export function changeSetHasNoChanges(description: CloudFormation.DescribeChangeSetOutput) { + return description.Status === 'FAILED' + && description.StatusReason + && description.StatusReason.startsWith('The submitted information didn\'t contain changes.'); } /** diff --git a/packages/aws-cdk/package-lock.json b/packages/aws-cdk/package-lock.json index f5c751b33620e..5afca94ce20d0 100644 --- a/packages/aws-cdk/package-lock.json +++ b/packages/aws-cdk/package-lock.json @@ -1,9 +1,45 @@ { "name": "aws-cdk", - "version": "0.25.0", + "version": "0.25.3", "lockfileVersion": 1, "requires": true, "dependencies": { + "@sinonjs/commons": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.4.0.tgz", + "integrity": "sha512-9jHK3YF/8HtJ9wCAbG+j8cD0i0+ATS9A7gXFqS36TblLPNy6rEEc+SB0imo91eCboGaBYGV/MT1/br/J+EE7Tw==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/formatio": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.2.1.tgz", + "integrity": "sha512-tsHvOB24rvyvV2+zKMmPkZ7dXX6LSLKZ7aOtXY6Edklp0uRcgGpOsQTTGTcWViFyx4uhWc6GV8QdnALbIbIdeQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1", + "@sinonjs/samsam": "^3.1.0" + } + }, + "@sinonjs/samsam": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-3.3.0.tgz", + "integrity": "sha512-beHeJM/RRAaLLsMJhsCvHK31rIqZuobfPLa/80yGH5hnD8PV1hyh9xJBJNFfNmO7yWqm+zomijHsXpI6iTQJfQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.0.2", + "array-from": "^2.1.1", + "lodash": "^4.17.11" + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", + "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", + "dev": true + }, "@types/archiver": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-2.1.2.tgz", @@ -62,7 +98,7 @@ }, "@types/mockery": { "version": "1.4.29", - "resolved": "https://registry.npmjs.org/@types/mockery/-/mockery-1.4.29.tgz", + "resolved": "http://registry.npmjs.org/@types/mockery/-/mockery-1.4.29.tgz", "integrity": "sha1-m6It838H43gP/4Ux0aOOYz+UV6U=", "dev": true }, @@ -89,6 +125,12 @@ "integrity": "sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==", "dev": true }, + "@types/sinon": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-7.0.10.tgz", + "integrity": "sha512-4w7SvsiUOtd4mUfund9QROPSJ5At/GQskDpqd87pJIRI6ULWSJqHI3GIZE337wQuN3aznroJGr94+o8fwvL37Q==", + "dev": true + }, "@types/table": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/table/-/table-4.0.5.tgz", @@ -182,6 +224,12 @@ "readable-stream": "^2.0.0" } }, + "array-from": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz", + "integrity": "sha1-z+nYwmYoudxa7MYqn12PHzUsEZU=", + "dev": true + }, "asn1": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", @@ -281,7 +329,7 @@ }, "bl": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz", + "resolved": "http://registry.npmjs.org/bl/-/bl-1.2.2.tgz", "integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==", "requires": { "readable-stream": "^2.3.5", @@ -357,7 +405,7 @@ }, "cli-color": { "version": "0.1.7", - "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-0.1.7.tgz", + "resolved": "http://registry.npmjs.org/cli-color/-/cli-color-0.1.7.tgz", "integrity": "sha1-rcMgD6RxzCEbDaf1ZrcemLnWc0c=", "requires": { "es5-ext": "0.8.x" @@ -388,7 +436,7 @@ }, "string-width": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "resolved": "http://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "requires": { "code-point-at": "^1.0.0", @@ -398,7 +446,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "requires": { "ansi-regex": "^2.0.0" @@ -547,6 +595,12 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true + }, "difflib": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/difflib/-/difflib-0.2.4.tgz", @@ -600,7 +654,7 @@ }, "es6-promisify": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "resolved": "http://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", "requires": { "es6-promise": "^4.0.3" @@ -635,7 +689,7 @@ }, "events": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "resolved": "http://registry.npmjs.org/events/-/events-1.1.1.tgz", "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" }, "execa": { @@ -741,7 +795,7 @@ }, "readable-stream": { "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", "requires": { "core-util-is": "~1.0.0", @@ -752,7 +806,7 @@ }, "string_decoder": { "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" }, "xregexp": { @@ -769,7 +823,7 @@ }, "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" }, "get-uri": { @@ -845,6 +899,12 @@ "har-schema": "^2.0.0" } }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, "heap": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.6.tgz", @@ -857,7 +917,7 @@ }, "http-errors": { "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "resolved": "http://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", "requires": { "depd": "~1.1.2", @@ -953,7 +1013,7 @@ }, "is-builtin-module": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", + "resolved": "http://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", "integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=", "requires": { "builtin-modules": "^1.0.0" @@ -1001,7 +1061,7 @@ }, "json-diff": { "version": "0.3.1", - "resolved": "https://registry.npmjs.org/json-diff/-/json-diff-0.3.1.tgz", + "resolved": "http://registry.npmjs.org/json-diff/-/json-diff-0.3.1.tgz", "integrity": "sha1-bbw64tJeB1p/1xvNmHRFhmb7aBs=", "requires": { "cli-color": "~0.1.6", @@ -1043,6 +1103,12 @@ "verror": "1.10.0" } }, + "just-extend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.0.2.tgz", + "integrity": "sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw==", + "dev": true + }, "lazystream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.0.tgz", @@ -1070,7 +1136,7 @@ }, "load-json-file": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", "requires": { "graceful-fs": "^4.1.2", @@ -1093,6 +1159,12 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" }, + "lolex": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-3.1.0.tgz", + "integrity": "sha512-zFo5MgCJ0rZ7gQg69S4pqBsLURbFw11X68C18OcJjJQbqaXm2NoTrGl1IMM3TIz0/BnN1tIs2tzmmqvCsOMMjw==", + "dev": true + }, "lru-cache": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", @@ -1157,6 +1229,27 @@ "resolved": "https://registry.npmjs.org/netmask/-/netmask-1.0.6.tgz", "integrity": "sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU=" }, + "nise": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/nise/-/nise-1.4.10.tgz", + "integrity": "sha512-sa0RRbj53dovjc7wombHmVli9ZihXbXCQ2uH3TNm03DyvOSIQbxg+pbqDKrk2oxMK1rtLGVlKxcB9rrc6X5YjA==", + "dev": true, + "requires": { + "@sinonjs/formatio": "^3.1.0", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "lolex": "^2.3.2", + "path-to-regexp": "^1.7.0" + }, + "dependencies": { + "lolex": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.5.tgz", + "integrity": "sha512-l9x0+1offnKKIzYVjyXU2SiwhXDLekRzKyhnbyldPHvC7BvLPVpdNUNR2KeMAiCN2D/kLNttZgQD5WjSxuBx3Q==", + "dev": true + } + } + }, "normalize-package-data": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.4.2.tgz", @@ -1293,7 +1386,7 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "path-key": { @@ -1301,6 +1394,23 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=" }, + "path-to-regexp": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", + "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", + "dev": true, + "requires": { + "isarray": "0.0.1" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + } + } + }, "path-type": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", @@ -1316,7 +1426,7 @@ }, "pify": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" }, "prelude-ls": { @@ -1422,7 +1532,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "requires": { "core-util-is": "~1.0.0", @@ -1488,7 +1598,7 @@ }, "sax": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "resolved": "http://registry.npmjs.org/sax/-/sax-1.2.1.tgz", "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" }, "semver": { @@ -1524,6 +1634,21 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" }, + "sinon": { + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-7.2.7.tgz", + "integrity": "sha512-rlrre9F80pIQr3M36gOdoCEWzFAMDgHYD8+tocqOw+Zw9OZ8F84a80Ds69eZfcjnzDqqG88ulFld0oin/6rG/g==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.3.1", + "@sinonjs/formatio": "^3.2.1", + "@sinonjs/samsam": "^3.2.0", + "diff": "^3.5.0", + "lolex": "^3.1.0", + "nise": "^1.4.10", + "supports-color": "^5.5.0" + } + }, "slice-ansi": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", @@ -1631,7 +1756,7 @@ }, "string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "requires": { "safe-buffer": "~5.1.0" @@ -1652,9 +1777,18 @@ }, "strip-eof": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "resolved": "http://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=" }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, "table": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/table/-/table-5.2.2.tgz", @@ -1727,6 +1861,12 @@ "prelude-ls": "~1.1.2" } }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, "universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -1810,7 +1950,7 @@ }, "wrap-ansi": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", "requires": { "string-width": "^1.0.1", @@ -1832,7 +1972,7 @@ }, "string-width": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "resolved": "http://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "requires": { "code-point-at": "^1.0.0", @@ -1842,7 +1982,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "requires": { "ansi-regex": "^2.0.0" @@ -1866,7 +2006,7 @@ }, "xmlbuilder": { "version": "9.0.7", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", + "resolved": "http://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" }, "xregexp": { diff --git a/packages/aws-cdk/package.json b/packages/aws-cdk/package.json index 23aa0a719a6c2..7a9d76df37604 100644 --- a/packages/aws-cdk/package.json +++ b/packages/aws-cdk/package.json @@ -42,8 +42,10 @@ "@types/uuid": "^3.4.3", "@types/yaml": "^1.0.0", "@types/yargs": "^8.0.3", + "@types/sinon": "^7.0.10", "cdk-build-tools": "^0.25.3", "mockery": "^2.1.0", + "sinon": "^7.2.7", "pkglint": "^0.25.3" }, "dependencies": { diff --git a/packages/aws-cdk/test/api/test.deploy-stack.ts b/packages/aws-cdk/test/api/test.deploy-stack.ts new file mode 100644 index 0000000000000..91b8cfb4e7b8a --- /dev/null +++ b/packages/aws-cdk/test/api/test.deploy-stack.ts @@ -0,0 +1,55 @@ +import { Test } from 'nodeunit'; +import { deployStack } from '../../lib'; +import { FakeSDK } from '../util/fake-sdk'; + +const FAKE_STACK = { + name: 'withouterrors', + template: { resource: 'noerrorresource' }, + environment: { name: 'dev', account: '12345', region: 'here' }, + metadata: {}, +}; + +export = { + async 'do deploy executable change set with 0 changes'(test: Test) { + // GIVEN + const sdk = new FakeSDK(); + + let executed = false; + + sdk.stubCloudFormation({ + describeStacks() { + return { + Stacks: [] + }; + }, + + createChangeSet() { + return {}; + }, + + describeChangeSet() { + return { + Status: 'CREATE_COMPLETE', + Changes: [], + }; + }, + + executeChangeSet() { + executed = true; + return {}; + } + }); + + // WHEN + const ret = await deployStack({ + stack: FAKE_STACK, + sdk, + }); + + // THEN + test.equals(ret.noOp, false); + test.equals(executed, true); + + test.done(); + }, +}; diff --git a/packages/aws-cdk/test/util/fake-sdk.ts b/packages/aws-cdk/test/util/fake-sdk.ts new file mode 100644 index 0000000000000..d0f08eb1146eb --- /dev/null +++ b/packages/aws-cdk/test/util/fake-sdk.ts @@ -0,0 +1,100 @@ +import AWS = require('aws-sdk'); +import sinon = require('sinon'); +import { SDK } from "../../lib/api/util/sdk"; + +/** + * An SDK that allows replacing (some of) the clients + * + * Its the responsibility of the consumer to replace all calls that + * actually will be called. + */ +export class FakeSDK extends SDK { + private readonly sandbox: sinon.SinonSandbox; + constructor() { + super(); + this.sandbox = sinon.createSandbox(); + } + + /** + * Replace the CloudFormation client with the given object + */ + public stubCloudFormation(stubs: SyncHandlerSubsetOf) { + this.sandbox.stub(this, 'cloudFormation').returns(Promise.resolve(partialAwsService(stubs))); + } +} + +/** + * Wrap synchronous fake handlers so that they sort-of function like a real AWS client + * + * For example, turns an object like this: + * + * ```ts + * { + * someCall(opts: AWS.Service.SomeCallInput): AWS.Service.SomeCallOutput { + * return {...whatever...}; + * } + * } + * ``` + * + * Into an object that in the type system pretends to be an 'AWS.Service' + * class (even though it really isn't) and can be called like this: + * + * ```ts + * const service = await sdk.someService(...); + * const response = await service.someCall(...).promise(); + * ``` + * + * We only implement the narrow subset of the AWS SDK API that the CDK actually + * uses, and we cheat on the types to make TypeScript happy on the rest of the API. + * + * Most important feature of this class is that it will derive the input and output + * types of the handlers on the input object from the ACTUAL AWS Service class, + * so that you don't have to declare them. + */ +function partialAwsService(fns: SyncHandlerSubsetOf): S { + // Super unsafe in here because I don't know how to make TypeScript happy, + // but at least the outer types make sure everything that happens in here works out. + const ret: any = {}; + + for (const [key, handler] of Object.entries(fns)) { + ret[key] = (args: any) => new FakeAWSResponse((handler as any)(args)); + } + + return ret; +} + +// Because of the overloads an AWS handler type looks like this: +// +// { +// (params: INPUTSTRUCT, callback?: ((err: AWSError, data: {}) => void) | undefined): Request; +// (callback?: ((err: AWS.AWSError, data: {}) => void) | undefined): AWS.Request<...>; +// } +// +// Get the first overload and extract the input and output struct types +type AwsCallInputOutput = + T extends { + (args: infer INPUT, callback?: ((err: AWS.AWSError, data: any) => void) | undefined): AWS.Request; + (callback?: ((err: AWS.AWSError, data: {}) => void) | undefined): AWS.Request; + } ? [INPUT, OUTPUT] : never; + +// Determine the type of the mock handler from the type of the Input/Output type pair. +// Don't need to worry about the 'never', TypeScript will propagate it upwards making it +// impossible to specify the field that has 'never' anywhere in its type. +type MockHandlerType = (input: AI[0]) => AI[1]; + +// Any subset of the full type that synchronously returns the output structure is okay +type SyncHandlerSubsetOf = {[K in keyof S]?: MockHandlerType>}; + +/** + * Fake AWS response. + * + * We only ever 'await response.promise()' so that's the only thing we implement here. + */ +class FakeAWSResponse { + constructor(private readonly x: T) { + } + + public promise(): Promise { + return Promise.resolve(this.x); + } +} From 54246e3f44ba244defa25b1c6b3a63b14410d501 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Mon, 18 Mar 2019 11:49:54 +0100 Subject: [PATCH 2/2] Review comments --- packages/aws-cdk/lib/api/util/cloudformation.ts | 6 +++--- packages/aws-cdk/test/api/test.deploy-stack.ts | 4 ++-- packages/aws-cdk/test/util/{fake-sdk.ts => mock-sdk.ts} | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename packages/aws-cdk/test/util/{fake-sdk.ts => mock-sdk.ts} (98%) diff --git a/packages/aws-cdk/lib/api/util/cloudformation.ts b/packages/aws-cdk/lib/api/util/cloudformation.ts index d07b67c7d4206..b5b621eadef06 100644 --- a/packages/aws-cdk/lib/api/util/cloudformation.ts +++ b/packages/aws-cdk/lib/api/util/cloudformation.ts @@ -130,9 +130,9 @@ export async function waitForChangeSet(cfn: CloudFormation, stackName: string, c * there are changes to Outputs, the change set can still be executed. */ export function changeSetHasNoChanges(description: CloudFormation.DescribeChangeSetOutput) { - return description.Status === 'FAILED' - && description.StatusReason - && description.StatusReason.startsWith('The submitted information didn\'t contain changes.'); + return description.Status === 'FAILED' + && description.StatusReason + && description.StatusReason.startsWith('The submitted information didn\'t contain changes.'); } /** diff --git a/packages/aws-cdk/test/api/test.deploy-stack.ts b/packages/aws-cdk/test/api/test.deploy-stack.ts index 91b8cfb4e7b8a..ef5e4ed614e7a 100644 --- a/packages/aws-cdk/test/api/test.deploy-stack.ts +++ b/packages/aws-cdk/test/api/test.deploy-stack.ts @@ -1,6 +1,6 @@ import { Test } from 'nodeunit'; import { deployStack } from '../../lib'; -import { FakeSDK } from '../util/fake-sdk'; +import { MockSDK } from '../util/mock-sdk'; const FAKE_STACK = { name: 'withouterrors', @@ -12,7 +12,7 @@ const FAKE_STACK = { export = { async 'do deploy executable change set with 0 changes'(test: Test) { // GIVEN - const sdk = new FakeSDK(); + const sdk = new MockSDK(); let executed = false; diff --git a/packages/aws-cdk/test/util/fake-sdk.ts b/packages/aws-cdk/test/util/mock-sdk.ts similarity index 98% rename from packages/aws-cdk/test/util/fake-sdk.ts rename to packages/aws-cdk/test/util/mock-sdk.ts index d0f08eb1146eb..008e44904f5df 100644 --- a/packages/aws-cdk/test/util/fake-sdk.ts +++ b/packages/aws-cdk/test/util/mock-sdk.ts @@ -8,7 +8,7 @@ import { SDK } from "../../lib/api/util/sdk"; * Its the responsibility of the consumer to replace all calls that * actually will be called. */ -export class FakeSDK extends SDK { +export class MockSDK extends SDK { private readonly sandbox: sinon.SinonSandbox; constructor() { super();