From 8dca5079e1893122057f9e2c54c0da0ba644926e Mon Sep 17 00:00:00 2001 From: Nick Lynch Date: Wed, 10 Mar 2021 11:51:11 +0000 Subject: [PATCH 01/14] chore(core): update CDK Metadata to report construct-level details (#13423) See CDK RFC 253 (aws/aws-cdk-rfcs#254) for background and details. Currently -- if a user has not opted out -- an AWS::CDK::Metadata resource is added to each generated stack template with details about each loaded module and version that matches an Amazon-specific allow list. This modules list is used to: - Track what library versions customers are using so they can be contacted in the event of a severe (security) issue with a library. - Get business metrics on the adoption of CDK and its libraries. This modules list is sometimes inaccurate (a module may be loaded into memory without actually being used) and too braod to support CDK v2. This feature (mostly) implements the specification proposed in RFC 253 to include metadata about what constructs are present in each stack, rather than modules loaded into memory. The allow-list is still used to ensure only CDK/AWS constructs are reported on. Implementation notes: - The format of the Analytics property has changed slightly since the RFC. See the service-side code for justification and latest spec. - How to handle the jsii runtime information was left un-spec'd. I've chosen to create a psuedo-Construct to add to the list as the simplest solution. - `runtime-info.test.ts` leaps through some serious hoops to work equally well for both v1 and v2, and to fail somewhat gracefully locally if `tsc` was used to compile the module instead of `jsii`. Critques of this approach welcome! - I removed an annoyance from `resolve-version-lib.js` that produced error messages when running unit tests. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../core/lib/private/metadata-resource.ts | 121 ++++++--- .../@aws-cdk/core/lib/private/runtime-info.ts | 143 +++++----- .../core/lib/private/tree-metadata.ts | 30 +-- packages/@aws-cdk/core/test/app.test.ts | 124 --------- .../core/test/metadata-resource.test.ts | 151 +++++++++++ .../@aws-cdk/core/test/runtime-info.test.ts | 252 +++++++++++++----- scripts/resolve-version-lib.js | 1 - 7 files changed, 480 insertions(+), 342 deletions(-) create mode 100644 packages/@aws-cdk/core/test/metadata-resource.test.ts diff --git a/packages/@aws-cdk/core/lib/private/metadata-resource.ts b/packages/@aws-cdk/core/lib/private/metadata-resource.ts index ff84b931f819b..1cccc4f24ff3d 100644 --- a/packages/@aws-cdk/core/lib/private/metadata-resource.ts +++ b/packages/@aws-cdk/core/lib/private/metadata-resource.ts @@ -1,4 +1,4 @@ -import * as cxapi from '@aws-cdk/cx-api'; +import * as zlib from 'zlib'; import { RegionInfo } from '@aws-cdk/region-info'; import { CfnCondition } from '../cfn-condition'; import { Fn } from '../cfn-fn'; @@ -8,41 +8,12 @@ import { Construct } from '../construct-compat'; import { Lazy } from '../lazy'; import { Stack } from '../stack'; import { Token } from '../token'; -import { collectRuntimeInformation } from './runtime-info'; +import { ConstructInfo, constructInfoFromStack } from './runtime-info'; /** * Construct that will render the metadata resource */ export class MetadataResource extends Construct { - /** - * Clear the modules cache - * - * The next time the MetadataResource is rendered, it will do a lookup of the - * modules from the NodeJS module cache again. - * - * Used only for unit tests. - */ - public static clearModulesCache() { - this._modulesPropertyCache = undefined; - } - - /** - * Cached version of the _modulesProperty() accessor - * - * No point in calculating this fairly expensive list more than once. - */ - private static _modulesPropertyCache?: string; - - /** - * Calculate the modules property - */ - private static modulesProperty(): string { - if (this._modulesPropertyCache === undefined) { - this._modulesPropertyCache = formatModules(collectRuntimeInformation()); - } - return this._modulesPropertyCache; - } - constructor(scope: Stack, id: string) { super(scope, id); @@ -51,7 +22,7 @@ export class MetadataResource extends Construct { const resource = new CfnResource(this, 'Default', { type: 'AWS::CDK::Metadata', properties: { - Modules: Lazy.string({ produce: () => MetadataResource.modulesProperty() }), + Analytics: Lazy.string({ produce: () => formatAnalytics(constructInfoFromStack(scope)) }), }, }); @@ -76,17 +47,81 @@ function makeCdkMetadataAvailableCondition() { .map(ri => Fn.conditionEquals(Aws.REGION, ri.name))); } -function formatModules(runtime: cxapi.RuntimeInfo): string { - const modules = new Array(); +/** Convenience type for arbitrarily-nested map */ +class Trie extends Map { } - // inject toolkit version to list of modules - const cliVersion = process.env[cxapi.CLI_VERSION_ENV]; - if (cliVersion) { - modules.push(`aws-cdk=${cliVersion}`); - } +/** + * Formats a list of construct fully-qualified names (FQNs) and versions into a (possibly compressed) prefix-encoded string. + * + * The list of ConstructInfos is logically formatted into: + * ${version}!${fqn} (e.g., "1.90.0!aws-cdk-lib.Stack") + * and then all of the construct-versions are grouped with common prefixes together, grouping common parts in '{}' and separating items with ','. + * + * Example: + * [1.90.0!aws-cdk-lib.Stack, 1.90.0!aws-cdk-lib.Construct, 1.90.0!aws-cdk-lib.service.Resource, 0.42.1!aws-cdk-lib-experiments.NewStuff] + * Becomes: + * 1.90.0!aws-cdk-lib.{Stack,Construct,service.Resource},0.42.1!aws-cdk-lib-experiments.NewStuff + * + * The whole thing is then either included directly as plaintext as: + * v2:plaintext:{prefixEncodedList} + * Or is compressed and base64-encoded, and then formatted as: + * v2:deflate64:{prefixEncodedListCompressedAndEncoded} + * + * Exported/visible for ease of testing. + */ +export function formatAnalytics(infos: ConstructInfo[]) { + const trie = new Trie(); + infos.forEach(info => insertFqnInTrie(`${info.version}!${info.fqn}`, trie)); - for (const key of Object.keys(runtime.libraries).sort()) { - modules.push(`${key}=${runtime.libraries[key]}`); + const plaintextEncodedConstructs = prefixEncodeTrie(trie); + const compressedConstructs = zlib.gzipSync(Buffer.from(plaintextEncodedConstructs)).toString('base64'); + + return `v2:deflate64:${compressedConstructs}`; +} + +/** + * Splits after non-alphanumeric characters (e.g., '.', '/') in the FQN + * and insert each piece of the FQN in nested map (i.e., simple trie). + */ +function insertFqnInTrie(fqn: string, trie: Trie) { + for (const fqnPart of fqn.replace(/[^a-z0-9]/gi, '$& ').split(' ')) { + const nextLevelTreeRef = trie.get(fqnPart) ?? new Trie(); + trie.set(fqnPart, nextLevelTreeRef); + trie = nextLevelTreeRef; } - return modules.join(','); -} \ No newline at end of file + return trie; +} + +/** + * Prefix-encodes a "trie-ish" structure, using '{}' to group and ',' to separate siblings. + * + * Example input: + * ABC,ABD,AEF + * + * Example trie: + * A --> B --> C + * | \--> D + * \--> E --> F + * + * Becomes: + * A{B{C,D},EF} + */ +function prefixEncodeTrie(trie: Trie) { + let prefixEncoded = ''; + let isFirstEntryAtLevel = true; + [...trie.entries()].forEach(([key, value]) => { + if (!isFirstEntryAtLevel) { + prefixEncoded += ','; + } + isFirstEntryAtLevel = false; + prefixEncoded += key; + if (value.size > 1) { + prefixEncoded += '{'; + prefixEncoded += prefixEncodeTrie(value); + prefixEncoded += '}'; + } else { + prefixEncoded += prefixEncodeTrie(value); + } + }); + return prefixEncoded; +} diff --git a/packages/@aws-cdk/core/lib/private/runtime-info.ts b/packages/@aws-cdk/core/lib/private/runtime-info.ts index b0cf266e8e11d..da4fbdcbe99d8 100644 --- a/packages/@aws-cdk/core/lib/private/runtime-info.ts +++ b/packages/@aws-cdk/core/lib/private/runtime-info.ts @@ -1,95 +1,82 @@ -import { basename, dirname } from 'path'; -import * as cxschema from '@aws-cdk/cloud-assembly-schema'; -import { major as nodeMajorVersion } from './node-version'; +import { IConstruct } from '../construct-compat'; +import { Stack } from '../stack'; +import { Stage } from '../stage'; -// list of NPM scopes included in version reporting e.g. @aws-cdk and @aws-solutions-konstruk -const ALLOWED_SCOPES = ['@aws-cdk', '@aws-cdk-containers', '@aws-solutions-konstruk', '@aws-solutions-constructs', '@amzn']; -// list of NPM packages included in version reporting -const ALLOWED_PACKAGES = ['aws-rfdk', 'aws-cdk-lib', 'monocdk']; +const ALLOWED_FQN_PREFIXES = [ + // SCOPES + '@aws-cdk/', '@aws-cdk-containers/', '@aws-solutions-konstruk/', '@aws-solutions-constructs/', '@amzn/', + // PACKAGES + 'aws-rfdk.', 'aws-cdk-lib.', 'monocdk.', +]; /** - * Returns a list of loaded modules and their versions. + * Symbol for accessing jsii runtime information + * + * Introduced in jsii 1.19.0, cdk 1.90.0. */ -export function collectRuntimeInformation(): cxschema.RuntimeInfo { - 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; - } - } +const JSII_RUNTIME_SYMBOL = Symbol.for('jsii.rtti'); - // include only libraries that are in the allowlistLibraries list - for (const name of Object.keys(libraries)) { - let foundMatch = false; - for (const scope of ALLOWED_SCOPES) { - if (name.startsWith(`${scope}/`)) { - foundMatch = true; - } - } - foundMatch = foundMatch || ALLOWED_PACKAGES.includes(name); +/** + * Source information on a construct (class fqn and version) + */ +export interface ConstructInfo { + readonly fqn: string; + readonly version: string; +} - if (!foundMatch) { - delete libraries[name]; - } +export function constructInfoFromConstruct(construct: IConstruct): ConstructInfo | undefined { + const jsiiRuntimeInfo = Object.getPrototypeOf(construct).constructor[JSII_RUNTIME_SYMBOL]; + if (typeof jsiiRuntimeInfo === 'object' + && jsiiRuntimeInfo !== null + && typeof jsiiRuntimeInfo.fqn === 'string' + && typeof jsiiRuntimeInfo.version === 'string') { + return { fqn: jsiiRuntimeInfo.fqn, version: jsiiRuntimeInfo.version }; + } else if (jsiiRuntimeInfo) { + // There is something defined, but doesn't match our expectations. Fail fast and hard. + throw new Error(`malformed jsii runtime info for construct: '${construct.node.path}'`); } - - // add jsii runtime version - libraries['jsii-runtime'] = getJsiiAgentVersion(); - - return { libraries }; + return undefined; } /** - * 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. + * For a given stack, walks the tree and finds the runtime info for all constructs within the tree. + * Returns the unique list of construct info present in the stack, + * as long as the construct fully-qualified names match the defined allow list. */ -function findNpmPackage(fileName: string): { name: string, version: string, private?: boolean } | undefined { - const mod = require.cache[fileName]; +export function constructInfoFromStack(stack: Stack): ConstructInfo[] { + const isDefined = (value: ConstructInfo | undefined): value is ConstructInfo => value !== undefined; - if (!mod?.paths) { - // sometimes this can be undefined. for example when querying for .json modules - // inside a jest runtime environment. - // see https://github.com/aws/aws-cdk/issues/7657 - // potentially we can remove this if it turns out to be a bug in how jest implemented the 'require' module. - return undefined; - } + const allConstructInfos = constructsInStack(stack) + .map(construct => constructInfoFromConstruct(construct)) + .filter(isDefined) + .filter(info => ALLOWED_FQN_PREFIXES.find(prefix => info.fqn.startsWith(prefix))); - // For any path in ``mod.paths`` that is a node_modules folder, use its parent directory instead. - const paths = mod?.paths.map((path: string) => basename(path) === 'node_modules' ? dirname(path) : path); + // Adds the jsii runtime as a psuedo construct for reporting purposes. + allConstructInfos.push({ + fqn: 'jsii-runtime.Runtime', + version: getJsiiAgentVersion(), + }); - try { - const packagePath = require.resolve( - // Resolution behavior changed in node 12.0.0 - https://github.com/nodejs/node/issues/27583 - nodeMajorVersion >= 12 ? './package.json' : 'package.json', - { paths }, - ); - // eslint-disable-next-line @typescript-eslint/no-require-imports - return require(packagePath); - } catch (e) { - return undefined; - } + // Filter out duplicate values + const uniqKeys = new Set(); + return allConstructInfos.filter(construct => { + const constructKey = `${construct.fqn}@${construct.version}`; + const isDuplicate = uniqKeys.has(constructKey); + uniqKeys.add(constructKey); + return !isDuplicate; + }); +} + +/** + * Returns all constructs under the parent construct (including the parent), + * stopping when it reaches a boundary of another stack (e.g., Stack, Stage, NestedStack). + */ +function constructsInStack(construct: IConstruct): IConstruct[] { + const constructs = [construct]; + construct.node.children + .filter(child => !Stage.isStage(child) && !Stack.isStack(child)) + .forEach(child => constructs.push(...constructsInStack(child))); + return constructs; } function getJsiiAgentVersion() { diff --git a/packages/@aws-cdk/core/lib/private/tree-metadata.ts b/packages/@aws-cdk/core/lib/private/tree-metadata.ts index caa5c37a5940d..97fe514bb4d87 100644 --- a/packages/@aws-cdk/core/lib/private/tree-metadata.ts +++ b/packages/@aws-cdk/core/lib/private/tree-metadata.ts @@ -6,16 +6,10 @@ import { Annotations } from '../annotations'; import { Construct, IConstruct, ISynthesisSession } from '../construct-compat'; import { Stack } from '../stack'; import { IInspectable, TreeInspector } from '../tree'; +import { ConstructInfo, constructInfoFromConstruct } from './runtime-info'; const FILE_PATH = 'tree.json'; -/** - * Symbol for accessing jsii runtime information - * - * Introduced in jsii 1.19.0, cdk 1.90.0. - */ -const JSII_RUNTIME_SYMBOL = Symbol.for('jsii.rtti'); - /** * Construct that is automatically attached to the top-level `App`. * This generates, as part of synthesis, a file containing the construct tree and the metadata for each node in the tree. @@ -48,14 +42,12 @@ export class TreeMetadata extends Construct { .filter((child) => child !== undefined) .reduce((map, child) => Object.assign(map, { [child!.id]: child }), {}); - const jsiiRuntimeInfo = Object.getPrototypeOf(construct).constructor[JSII_RUNTIME_SYMBOL]; - const node: Node = { id: construct.node.id || 'App', path: construct.node.path, children: Object.keys(childrenMap).length === 0 ? undefined : childrenMap, attributes: this.synthAttributes(construct), - constructInfo: constructInfoFromRuntimeInfo(jsiiRuntimeInfo), + constructInfo: constructInfoFromConstruct(construct), }; lookup[node.path] = node; @@ -96,16 +88,6 @@ export class TreeMetadata extends Construct { } } -function constructInfoFromRuntimeInfo(jsiiRuntimeInfo: any): ConstructInfo | undefined { - if (typeof jsiiRuntimeInfo === 'object' - && jsiiRuntimeInfo !== null - && typeof jsiiRuntimeInfo.fqn === 'string' - && typeof jsiiRuntimeInfo.version === 'string') { - return { fqn: jsiiRuntimeInfo.fqn, version: jsiiRuntimeInfo.version }; - } - return undefined; -} - interface Node { readonly id: string; readonly path: string; @@ -117,11 +99,3 @@ interface Node { */ readonly constructInfo?: ConstructInfo; } - -/** - * Source information on a construct (class fqn and version) - */ -interface ConstructInfo { - readonly fqn: string; - readonly version: string; -} diff --git a/packages/@aws-cdk/core/test/app.test.ts b/packages/@aws-cdk/core/test/app.test.ts index 69486987f0085..199b36dc87465 100644 --- a/packages/@aws-cdk/core/test/app.test.ts +++ b/packages/@aws-cdk/core/test/app.test.ts @@ -4,7 +4,6 @@ import { nodeunitShim, Test } from 'nodeunit-shim'; import { CfnResource, Construct, Stack, StackProps } from '../lib'; import { Annotations } from '../lib/annotations'; import { App, AppProps } from '../lib/app'; -import { MetadataResource } from '../lib/private/metadata-resource'; function withApp(props: AppProps, block: (app: App) => void): cxapi.CloudAssembly { const app = new App({ @@ -260,90 +259,6 @@ nodeunitShim({ test.done(); }, - 'runtime library versions'(test: Test) { - v1(() => { - MetadataResource.clearModulesCache(); - - const response = withApp({ analyticsReporting: true }, app => { - const stack = new Stack(app, 'stack1'); - new CfnResource(stack, 'MyResource', { type: 'Resource::Type' }); - }); - - const stackTemplate = response.getStackByName('stack1').template; - const libs = parseModules(stackTemplate.Resources?.CDKMetadata?.Properties?.Modules); - - // eslint-disable-next-line @typescript-eslint/no-require-imports - const version = require('../package.json').version; - test.deepEqual(libs['@aws-cdk/core'], version); - test.deepEqual(libs['@aws-cdk/cx-api'], version); - test.deepEqual(libs['jsii-runtime'], `node.js/${process.version}`); - }); - test.done(); - }, - - 'CDK version'(test: Test) { - MetadataResource.clearModulesCache(); - - withCliVersion(() => { - const response = withApp({ analyticsReporting: true }, app => { - const stack = new Stack(app, 'stack1'); - new CfnResource(stack, 'MyResource', { type: 'Resource::Type' }); - }); - - const stackTemplate = response.getStackByName('stack1').template; - const libs = parseModules(stackTemplate.Resources?.CDKMetadata?.Properties?.Modules); - - // eslint-disable-next-line @typescript-eslint/no-require-imports - test.deepEqual(libs['aws-cdk'], '1.2.3'); - }); - - test.done(); - }, - - 'jsii-runtime version loaded from JSII_AGENT'(test: Test) { - process.env.JSII_AGENT = 'Java/1.2.3.4'; - MetadataResource.clearModulesCache(); - - withCliVersion(() => { - const response = withApp({ analyticsReporting: true }, app => { - const stack = new Stack(app, 'stack1'); - new CfnResource(stack, 'MyResource', { type: 'Resource::Type' }); - }); - - const stackTemplate = response.getStackByName('stack1').template; - const libs = parseModules(stackTemplate.Resources?.CDKMetadata?.Properties?.Modules); - - test.deepEqual(libs['jsii-runtime'], 'Java/1.2.3.4'); - }); - - delete process.env.JSII_AGENT; - test.done(); - }, - - 'version reporting includes only @aws-cdk, aws-cdk and jsii libraries'(test: Test) { - v1(() => { - MetadataResource.clearModulesCache(); - - const response = withApp({ analyticsReporting: true }, app => { - const stack = new Stack(app, 'stack1'); - new CfnResource(stack, 'MyResource', { type: 'Resource::Type' }); - }); - - const stackTemplate = response.getStackByName('stack1').template; - const libs = parseModules(stackTemplate.Resources?.CDKMetadata?.Properties?.Modules); - const libNames = Object.keys(libs).sort(); - - test.deepEqual(libNames, [ - '@aws-cdk/cloud-assembly-schema', - '@aws-cdk/core', - '@aws-cdk/cx-api', - '@aws-cdk/region-info', - 'jsii-runtime', - ]); - }); - test.done(); - }, - 'deep stack is shown and synthesized properly'(test: Test) { // WHEN const response = withApp({}, (app) => { @@ -420,42 +335,3 @@ class MyConstruct extends Construct { new CfnResource(this, 'r2', { type: 'ResourceType2', properties: { FromContext: this.node.tryGetContext('ctx1') } }); } } - -function parseModules(x?: string): Record { - if (x === undefined) { return {}; } - - const ret: Record = {}; - for (const clause of x.split(',')) { - const [key, value] = clause.split('='); - if (key !== undefined && value !== undefined) { - ret[key] = value; - } - } - return ret; -} - -/** - * Set the CLI_VERSION_ENV environment variable - * - * This is necessary to get the Stack to emit the metadata resource - */ -function withCliVersion(block: () => A): A { - process.env[cxapi.CLI_VERSION_ENV] = '1.2.3'; - try { - return block(); - } finally { - delete process.env[cxapi.CLI_VERSION_ENV]; - } -} - -function v1(block: () => void) { - onVersion(1, block); -} - -function onVersion(version: number, block: () => void) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const mv: number = require('../../../../release.json').majorVersion; - if (version === mv) { - block(); - } -} \ No newline at end of file diff --git a/packages/@aws-cdk/core/test/metadata-resource.test.ts b/packages/@aws-cdk/core/test/metadata-resource.test.ts new file mode 100644 index 0000000000000..2275bcf7dee9d --- /dev/null +++ b/packages/@aws-cdk/core/test/metadata-resource.test.ts @@ -0,0 +1,151 @@ +import * as zlib from 'zlib'; +import { App, Stack } from '../lib'; +import { formatAnalytics } from '../lib/private/metadata-resource'; + +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct } from '../lib'; +import { ConstructInfo } from '../lib/private/runtime-info'; + +describe('MetadataResource', () => { + let app: App; + let stack: Stack; + + beforeEach(() => { + app = new App({ + analyticsReporting: true, + }); + stack = new Stack(app, 'Stack'); + }); + + test('is not included if the region is known and metadata is not available', () => { + new Stack(app, 'StackUnavailable', { + env: { region: 'definitely-no-metadata-resource-available-here' }, + }); + + const stackTemplate = app.synth().getStackByName('StackUnavailable').template; + + expect(stackTemplate.Resources?.CDKMetadata).toBeUndefined(); + }); + + test('is included if the region is known and metadata is available', () => { + new Stack(app, 'StackPresent', { + env: { region: 'us-east-1' }, + }); + + const stackTemplate = app.synth().getStackByName('StackPresent').template; + + expect(stackTemplate.Resources?.CDKMetadata).toBeDefined(); + }); + + test('is included if the region is unknown with conditions', () => { + new Stack(app, 'StackUnknown'); + + const stackTemplate = app.synth().getStackByName('StackUnknown').template; + + expect(stackTemplate.Resources?.CDKMetadata).toBeDefined(); + expect(stackTemplate.Resources?.CDKMetadata?.Condition).toBeDefined(); + }); + + test('includes the formatted Analytics property', () => { + // A very simple check that the jsii runtime psuedo-construct is present. + // This check works whether we're running locally or on CodeBuild, on v1 or v2. + // Other tests(in app.test.ts) will test version-specific results. + expect(stackAnalytics()).toMatch(/jsii-runtime.Runtime/); + }); + + test('includes the current jsii runtime version', () => { + process.env.JSII_AGENT = 'Java/1.2.3.4'; + + expect(stackAnalytics()).toContain('Java/1.2.3.4!jsii-runtime.Runtime'); + delete process.env.JSII_AGENT; + }); + + test('includes constructs added to the stack', () => { + new TestConstruct(stack, 'Test'); + + expect(stackAnalytics()).toContain('1.2.3!@amzn/core.TestConstruct'); + }); + + test('only includes constructs in the allow list', () => { + new TestThirdPartyConstruct(stack, 'Test'); + + expect(stackAnalytics()).not.toContain('TestConstruct'); + }); + + function stackAnalytics(stackName: string = 'Stack') { + const encodedAnalytics = app.synth().getStackByName(stackName).template.Resources?.CDKMetadata?.Properties?.Analytics as string; + return plaintextConstructsFromAnalytics(encodedAnalytics); + } +}); + +describe('formatAnalytics', () => { + test('analytics are formatted with a prefix of v2:deflate64:', () => { + const constructInfo = [{ fqn: 'aws-cdk-lib.Construct', version: '1.2.3' }]; + + expect(formatAnalytics(constructInfo)).toMatch(/v2:deflate64:.*/); + }); + + test('single construct', () => { + const constructInfo = [{ fqn: 'aws-cdk-lib.Construct', version: '1.2.3' }]; + + expectAnalytics(constructInfo, '1.2.3!aws-cdk-lib.Construct'); + }); + + test('common prefixes with same versions are combined', () => { + const constructInfo = [ + { fqn: 'aws-cdk-lib.Construct', version: '1.2.3' }, + { fqn: 'aws-cdk-lib.CfnResource', version: '1.2.3' }, + { fqn: 'aws-cdk-lib.Stack', version: '1.2.3' }, + ]; + + expectAnalytics(constructInfo, '1.2.3!aws-cdk-lib.{Construct,CfnResource,Stack}'); + }); + + test('nested modules with common prefixes and same versions are combined', () => { + const constructInfo = [ + { fqn: 'aws-cdk-lib.Construct', version: '1.2.3' }, + { fqn: 'aws-cdk-lib.CfnResource', version: '1.2.3' }, + { fqn: 'aws-cdk-lib.Stack', version: '1.2.3' }, + { fqn: 'aws-cdk-lib.aws_servicefoo.CoolResource', version: '1.2.3' }, + { fqn: 'aws-cdk-lib.aws_servicefoo.OtherResource', version: '1.2.3' }, + ]; + + expectAnalytics(constructInfo, '1.2.3!aws-cdk-lib.{Construct,CfnResource,Stack,aws_servicefoo.{CoolResource,OtherResource}}'); + }); + + test('constructs are grouped by version', () => { + const constructInfo = [ + { fqn: 'aws-cdk-lib.Construct', version: '1.2.3' }, + { fqn: 'aws-cdk-lib.CfnResource', version: '1.2.3' }, + { fqn: 'aws-cdk-lib.Stack', version: '1.2.3' }, + { fqn: 'aws-cdk-lib.CoolResource', version: '0.1.2' }, + { fqn: 'aws-cdk-lib.OtherResource', version: '0.1.2' }, + ]; + + expectAnalytics(constructInfo, '1.2.3!aws-cdk-lib.{Construct,CfnResource,Stack},0.1.2!aws-cdk-lib.{CoolResource,OtherResource}'); + }); + + // Compares the output of formatAnalytics with an expected (plaintext) output. + // For ease of testing, the plaintext versions are compared rather than the encoded versions. + function expectAnalytics(constructs: ConstructInfo[], expectedPlaintext: string) { + expect(plaintextConstructsFromAnalytics(formatAnalytics(constructs))).toEqual(expectedPlaintext); + } + +}); + +function plaintextConstructsFromAnalytics(analytics: string) { + return zlib.gunzipSync(Buffer.from(analytics.split(':')[2], 'base64')).toString('utf-8'); +} + +const JSII_RUNTIME_SYMBOL = Symbol.for('jsii.rtti'); + +class TestConstruct extends Construct { + // @ts-ignore + private static readonly [JSII_RUNTIME_SYMBOL] = { fqn: '@amzn/core.TestConstruct', version: '1.2.3' } +} + +class TestThirdPartyConstruct extends Construct { + // @ts-ignore + private static readonly [JSII_RUNTIME_SYMBOL] = { fqn: 'mycoolthing.TestConstruct', version: '1.2.3' } +} + diff --git a/packages/@aws-cdk/core/test/runtime-info.test.ts b/packages/@aws-cdk/core/test/runtime-info.test.ts index 67f931bb63ec5..7da4f78d74b46 100644 --- a/packages/@aws-cdk/core/test/runtime-info.test.ts +++ b/packages/@aws-cdk/core/test/runtime-info.test.ts @@ -1,73 +1,189 @@ -import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; -import { nodeunitShim, Test } from 'nodeunit-shim'; -import { collectRuntimeInformation } from '../lib/private/runtime-info'; - -nodeunitShim({ - 'version reporting includes @aws-solutions-konstruk libraries'(test: Test) { - const pkgdir = fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-info-konstruk-fixture')); - const mockVersion = '1.2.3'; - - fs.writeFileSync(path.join(pkgdir, 'index.js'), 'module.exports = \'this is foo\';'); - fs.writeFileSync(path.join(pkgdir, 'package.json'), JSON.stringify({ - name: '@aws-solutions-konstruk/foo', - version: mockVersion, - })); - - // eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-extraneous-dependencies - require(pkgdir); - - const runtimeInfo = collectRuntimeInformation(); - - // eslint-disable-next-line @typescript-eslint/no-require-imports - test.deepEqual(runtimeInfo.libraries['@aws-solutions-konstruk/foo'], mockVersion); - test.done(); - }, - - 'version reporting finds aws-rfdk package'(test: Test) { - const pkgdir = fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-info-rfdk')); - const mockVersion = '1.2.3'; - - fs.writeFileSync(path.join(pkgdir, 'index.js'), 'module.exports = \'this is foo\';'); - fs.writeFileSync(path.join(pkgdir, 'package.json'), JSON.stringify({ - name: 'aws-rfdk', - version: mockVersion, - })); +import { App, NestedStack, Stack, Stage } from '../lib'; +import { constructInfoFromConstruct, constructInfoFromStack } from '../lib/private/runtime-info'; + +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct } from '../lib'; + +const JSII_RUNTIME_SYMBOL = Symbol.for('jsii.rtti'); + +let app: App; +let stack: Stack; +let _cdkVersion: string | undefined = undefined; +const modulePrefix = cdkMajorVersion() === 1 ? '@aws-cdk/core' : 'aws-cdk-lib'; + +// The runtime metadata this test relies on is only available if the most +// recent compile has happened using 'jsii', as the jsii compiler injects +// this metadata. +// +// If the most recent compile was using 'tsc', the metadata will not have +// been injected, and the test suite will fail. +// +// Tolerate `tsc` builds locally, but not on CodeBuild. +const codeBuild = !!process.env.CODEBUILD_BUILD_ID; +const moduleCompiledWithTsc = constructInfoFromConstruct(new Stack())?.fqn === 'constructs.Construct'; +let describeTscSafe = describe; +if (moduleCompiledWithTsc && !codeBuild) { + // eslint-disable-next-line + console.error('It appears this module was compiled with `tsc` instead of `jsii` in a local build. Skipping this test suite.'); + describeTscSafe = describe.skip; +} + +beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack', { + analyticsReporting: true, + }); +}); - // eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-extraneous-dependencies - require(pkgdir); +describeTscSafe('constructInfoFromConstruct', () => { + test('returns fqn and version for core constructs', () => { + const constructInfo = constructInfoFromConstruct(stack); + expect(constructInfo).toBeDefined(); + expect(constructInfo?.fqn).toEqual(`${modulePrefix}.Stack`); + expect(constructInfo?.version).toEqual(localCdkVersion()); + }); + + test('returns base construct info if no more specific info is present', () => { + const simpleConstruct = new class extends Construct { }(stack, 'Simple'); + const constructInfo = constructInfoFromConstruct(simpleConstruct); + expect(constructInfo?.fqn).toEqual(`${modulePrefix}.Construct`); + }); + + test('returns more specific subclass info if present', () => { + const construct = new class extends Construct { + // @ts-ignore + private static readonly [JSII_RUNTIME_SYMBOL] = { fqn: 'aws-cdk-lib.TestConstruct', version: localCdkVersion() } + }(stack, 'TestConstruct'); + + const constructInfo = constructInfoFromConstruct(construct); + expect(constructInfo?.fqn).toEqual('aws-cdk-lib.TestConstruct'); + }); + + test('throws if the jsii runtime info is not as expected', () => { + const constructRuntimeInfoNotObject = new class extends Construct { + // @ts-ignore + private static readonly [JSII_RUNTIME_SYMBOL] = 'HelloWorld'; + }(stack, 'RuntimeNotObject'); + const constructWithWrongRuntimeInfoMembers = new class extends Construct { + // @ts-ignore + private static readonly [JSII_RUNTIME_SYMBOL] = { foo: 'bar' }; + }(stack, 'RuntimeWrongMembers'); + const constructWithWrongRuntimeInfoTypes = new class extends Construct { + // @ts-ignore + private static readonly [JSII_RUNTIME_SYMBOL] = { fqn: 42, version: { name: '0.0.0' } }; + }(stack, 'RuntimeWrongTypes'); + + const errorMessage = 'malformed jsii runtime info for construct'; + [constructRuntimeInfoNotObject, constructWithWrongRuntimeInfoMembers, constructWithWrongRuntimeInfoTypes].forEach(construct => { + expect(() => constructInfoFromConstruct(construct)).toThrow(errorMessage); + }); + }); +}); - const runtimeInfo = collectRuntimeInformation(); +describeTscSafe('constructInfoForStack', () => { + test('returns stack itself and jsii runtime if stack is empty', () => { + const constructInfos = constructInfoFromStack(stack); + + expect(constructInfos.length).toEqual(2); + + const stackInfo = constructInfos.find(i => /Stack/.test(i.fqn)); + const jsiiInfo = constructInfos.find(i => i.fqn === 'jsii-runtime.Runtime'); + expect(stackInfo?.fqn).toEqual(`${modulePrefix}.Stack`); + expect(stackInfo?.version).toEqual(localCdkVersion()); + expect(jsiiInfo?.version).toMatch(/node.js/); + }); + + test('returns info for constructs added to the stack', () => { + new class extends Construct { }(stack, 'Simple'); + + const constructInfos = constructInfoFromStack(stack); + + expect(constructInfos.length).toEqual(3); + expect(constructInfos.map(info => info.fqn)).toContain(`${modulePrefix}.Construct`); + }); + + test('returns unique info (no duplicates)', () => { + new class extends Construct { }(stack, 'Simple1'); + new class extends Construct { }(stack, 'Simple2'); + + const constructInfos = constructInfoFromStack(stack); + + expect(constructInfos.length).toEqual(3); + expect(constructInfos.map(info => info.fqn)).toContain(`${modulePrefix}.Construct`); + }); + + test('returns info from nested constructs', () => { + new class extends Construct { + constructor(scope: Construct, id: string) { + super(scope, id); + return new class extends Construct { + // @ts-ignore + private static readonly [JSII_RUNTIME_SYMBOL] = { fqn: '@aws-cdk/test.TestV1Construct', version: localCdkVersion() } + }(this, 'TestConstruct'); + } + }(stack, 'Nested'); + + const constructInfos = constructInfoFromStack(stack); + + expect(constructInfos.length).toEqual(4); + expect(constructInfos.map(info => info.fqn)).toContain('@aws-cdk/test.TestV1Construct'); + }); + + test('does not return info from nested stacks', () => { + new class extends Construct { + constructor(scope: Construct, id: string) { + super(scope, id); + + new class extends Construct { + // @ts-ignore + private static readonly [JSII_RUNTIME_SYMBOL] = { fqn: '@aws-cdk/test.TestV1Construct', version: localCdkVersion() } + }(this, 'TestConstruct'); + + new class extends Stack { + // @ts-ignore + private static readonly [JSII_RUNTIME_SYMBOL] = { fqn: '@aws-cdk/test.TestStackInsideStack', version: localCdkVersion() } + }(this, 'StackInsideStack'); + + new class extends NestedStack { + // @ts-ignore + private static readonly [JSII_RUNTIME_SYMBOL] = { fqn: '@aws-cdk/test.TestNestedStackInsideStack', version: localCdkVersion() } + }(this, 'NestedStackInsideStack'); + + new class extends Stage { + // @ts-ignore + private static readonly [JSII_RUNTIME_SYMBOL] = { fqn: '@aws-cdk/test.TestStageInsideStack', version: localCdkVersion() } + }(this, 'StageInsideStack'); + } + }(stack, 'ParentConstruct'); + + const constructInfos = constructInfoFromStack(stack); + + const fqns = constructInfos.map(info => info.fqn); + expect(fqns).toContain('@aws-cdk/test.TestV1Construct'); + expect(fqns).not.toContain('@aws-cdk/test.TestStackInsideStack'); + expect(fqns).not.toContain('@aws-cdk/test.TestNestedStackInsideStack'); + expect(fqns).not.toContain('@aws-cdk/test.TestStageInsideStack'); + }); +}); +function cdkMajorVersion(): number { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('../../../../release.json').majorVersion; +} + +/** + * The exact values we expect from testing against version numbers in this suite depend on whether we're running + * on a development or release branch. Returns the local package.json version, which will be '0.0.0' unless we're + * on a release branch, in which case it should be the real version numbers (e.g., 1.91.0). + */ +function localCdkVersion(): string { + if (!_cdkVersion) { // eslint-disable-next-line @typescript-eslint/no-require-imports - test.deepEqual(runtimeInfo.libraries['aws-rfdk'], mockVersion); - test.done(); - }, - - 'version reporting finds no version with no associated package.json'(test: Test) { - const pkgdir = fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-info-find-npm-package-fixture')); - const mockVersion = '1.2.3'; - - fs.writeFileSync(path.join(pkgdir, 'index.js'), 'module.exports = \'this is bar\';'); - fs.mkdirSync(path.join(pkgdir, 'bar')); - fs.writeFileSync(path.join(pkgdir, 'bar', 'package.json'), JSON.stringify({ - name: '@aws-solutions-konstruk/bar', - version: mockVersion, - })); - - // eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-extraneous-dependencies - require(pkgdir); - - const cwd = process.cwd(); - - // Switch to `bar` where the package.json is, then resolve version. Fails when module.resolve - // is passed an empty string in the paths array. - process.chdir(path.join(pkgdir, 'bar')); - const runtimeInfo = collectRuntimeInformation(); - process.chdir(cwd); - - test.equal(runtimeInfo.libraries['@aws-solutions-konstruk/bar'], undefined); - test.done(); - }, -}); + _cdkVersion = require(path.join('..', 'package.json')).version; + if (!_cdkVersion) { + throw new Error('Unable to determine CDK version'); + } + } + return _cdkVersion; +} diff --git a/scripts/resolve-version-lib.js b/scripts/resolve-version-lib.js index 2a7f0e4eecebc..21a13c0eb4ab2 100755 --- a/scripts/resolve-version-lib.js +++ b/scripts/resolve-version-lib.js @@ -38,7 +38,6 @@ function resolveVersion(rootdir) { // const currentVersion = require(versionFilePath).version; - console.error(`current version: ${currentVersion}`); if (!currentVersion.startsWith(`${majorVersion}.`)) { throw new Error(`current version "${currentVersion}" does not use the expected major version ${majorVersion}`); } From bc1293b8062a0792d6d1d9f058f308c72475e778 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 10 Mar 2021 13:25:51 +0100 Subject: [PATCH 02/14] refactor(core): refactor `CloudFormationLang.toJSON()` (#11224) Our previous implementation of `toJSON()` was quite hacky. It replaced values inside the structure with objects that had a custom `toJSON()` serializer, and then called `JSON.stringify()` on the result. The resulting JSON would have special markers in it where the Token values would be string-substituted back in. It's actually easier and gives us more control to just implement JSONification ourselves in a Token-aware recursive function. This change has been split off from a larger, upcoming PR in order to make the individual reviews smaller. Incidentally also fixes #13465, as the type of encoded tokens is assumed to match the type of the encoded value (e.g., a `string[]`-encoded token is assumed to produce a list at deploy-time and so will not be quoted). ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../core/lib/private/cloudformation-lang.ts | 346 ++++++++++++++---- packages/@aws-cdk/core/lib/private/resolve.ts | 137 ++++++- .../@aws-cdk/core/lib/private/token-map.ts | 3 +- packages/@aws-cdk/core/lib/resolvable.ts | 14 +- .../core/test/cloudformation-json.test.ts | 329 ++++++++++++----- packages/@aws-cdk/core/test/evaluate-cfn.ts | 14 +- 6 files changed, 645 insertions(+), 198 deletions(-) diff --git a/packages/@aws-cdk/core/lib/private/cloudformation-lang.ts b/packages/@aws-cdk/core/lib/private/cloudformation-lang.ts index 4a74665b8f338..310a4632f4e8f 100644 --- a/packages/@aws-cdk/core/lib/private/cloudformation-lang.ts +++ b/packages/@aws-cdk/core/lib/private/cloudformation-lang.ts @@ -1,10 +1,7 @@ import { Lazy } from '../lazy'; -import { Reference } from '../reference'; -import { DefaultTokenResolver, IFragmentConcatenator, IPostProcessor, IResolvable, IResolveContext } from '../resolvable'; -import { TokenizedStringFragments } from '../string-fragments'; +import { DefaultTokenResolver, IFragmentConcatenator, IResolveContext } from '../resolvable'; import { Token } from '../token'; -import { Intrinsic } from './intrinsic'; -import { resolve } from './resolve'; +import { INTRINSIC_KEY_PREFIX, ResolutionTypeHint, resolvedTypeHint } from './resolve'; /** * Routines that know how to do operations at the CloudFormation document language level @@ -24,59 +21,12 @@ export class CloudFormationLang { * @param space Indentation to use (default: no pretty-printing) */ public static toJSON(obj: any, space?: number): string { - // This works in two stages: - // - // First, resolve everything. This gets rid of the lazy evaluations, evaluation - // to the real types of things (for example, would a function return a string, an - // intrinsic, or a number? We have to resolve to know). - // - // We then to through the returned result, identify things that evaluated to - // CloudFormation intrinsics, and re-wrap those in Tokens that have a - // toJSON() method returning their string representation. If we then call - // JSON.stringify() on that result, that gives us essentially the same - // string that we started with, except with the non-token characters quoted. - // - // {"field": "${TOKEN}"} --> {\"field\": \"${TOKEN}\"} - // - // A final resolve() on that string (done by the framework) will yield the string - // we're after. - // - // Resolving and wrapping are done in go using the resolver framework. - class IntrinsincWrapper extends DefaultTokenResolver { - constructor() { - super(CLOUDFORMATION_CONCAT); - } - - public resolveToken(t: IResolvable, context: IResolveContext, postProcess: IPostProcessor) { - // Return References directly, so their type is maintained and the references will - // continue to work. Only while preparing, because we do need the final value of the - // token while resolving. - if (Reference.isReference(t) && context.preparing) { return wrap(t); } - - // Deep-resolve and wrap. This is necessary for Lazy tokens so we can see "inside" them. - return wrap(super.resolveToken(t, context, postProcess)); - } - public resolveString(fragments: TokenizedStringFragments, context: IResolveContext) { - return wrap(super.resolveString(fragments, context)); - } - public resolveList(l: string[], context: IResolveContext) { - return wrap(super.resolveList(l, context)); - } - } - - // We need a ResolveContext to get started so return a Token - return Lazy.stringValue({ - produce: (ctx: IResolveContext) => - JSON.stringify(resolve(obj, { - preparing: ctx.preparing, - scope: ctx.scope, - resolver: new IntrinsincWrapper(), - }), undefined, space), + return Lazy.uncachedString({ + // We used to do this by hooking into `JSON.stringify()` by adding in objects + // with custom `toJSON()` functions, but it's ultimately simpler just to + // reimplement the `stringify()` function from scratch. + produce: (ctx) => tokenAwareStringify(obj, space ?? 0, ctx), }); - - function wrap(value: any): any { - return isIntrinsic(value) ? new JsonToken(deepQuoteStringsForJSON(value)) : value; - } } /** @@ -97,44 +47,227 @@ export class CloudFormationLang { // Otherwise return a Join intrinsic (already in the target document language to avoid taking // circular dependencies on FnJoin & friends) - return { 'Fn::Join': ['', minimalCloudFormationJoin('', parts)] }; + return fnJoinConcat(parts); } } /** - * Token that also stringifies in the toJSON() operation. + * Return a CFN intrinsic mass concatting any number of CloudFormation expressions */ -class JsonToken extends Intrinsic { - /** - * Special handler that gets called when JSON.stringify() is used. - */ - public toJSON() { - return this.toString(); - } +function fnJoinConcat(parts: any[]) { + return { 'Fn::Join': ['', minimalCloudFormationJoin('', parts)] }; } /** - * Deep escape strings for use in a JSON context + * Perform a JSON.stringify()-like operation, except aware of Tokens and CloudFormation intrincics + * + * Tokens will be resolved and if any resolve to CloudFormation intrinsics, the intrinsics + * will be lifted to the top of a giant `{ Fn::Join }` expression. + * + * If Tokens resolve to primitive types (for example, by using Lazies), we'll + * use the primitive type to determine how to encode the value into the JSON. + * + * If Tokens resolve to CloudFormation intrinsics, we'll use the type of the encoded + * value as a type hint to determine how to encode the value into the JSON. The difference + * is that we add quotes (") around strings, and don't add anything around non-strings. + * + * The following structure: + * + * { SomeAttr: resource.someAttr } + * + * Will JSONify to either: + * + * '{ "SomeAttr": "' ++ { Fn::GetAtt: [Resource, SomeAttr] } ++ '" }' + * or '{ "SomeAttr": ' ++ { Fn::GetAtt: [Resource, SomeAttr] } ++ ' }' + * + * Depending on whether `someAttr` is type-hinted to be a string or not. + * + * (Where ++ is the CloudFormation string-concat operation (`{ Fn::Join }`). + * + * ----------------------- + * + * This work requires 2 features from the `resolve()` function: + * + * - INTRINSICS TYPE HINTS: intrinsics are represented by values like + * `{ Ref: 'XYZ' }`. These values can reference either a string or a list/number at + * deploy time, and from the value alone there's no way to know which. We need + * to know the type to know whether to JSONify this reference to: + * + * '{ "referencedValue": "' ++ { Ref: XYZ } ++ '"}' + * or '{ "referencedValue": ' ++ { Ref: XYZ } ++ '}' + * + * I.e., whether or not we need to enclose the reference in quotes or not. + * + * We COULD have done this by resolving one token at a time, and looking at the + * type of the encoded token we were resolving to obtain a type hint. However, + * the `resolve()` and Token system resist a level-at-a-time resolve + * operation: because of the existence of post-processors, we must have done a + * complete recursive resolution of a token before we can look at its result + * (after which any type information about the sources of nested resolved + * values is lost). + * + * To fix this, "type hints" have been added to the `resolve()` function, + * giving an idea of the type of the source value for compplex result values. + * This only works for objects (not strings and numbers) but fortunately + * we only care about the types of intrinsics, which are always complex values. + * + * Type hinting could have been added to the `IResolvable` protocol as well, + * but for now we just use the type of an encoded value as a type hint. That way + * we don't need to annotate anything more at the L1 level--we will use the type + * encodings added by construct authors at the L2 levels. L1 users can escape the + * default decision of "string" by using `Token.asList()`. + * + * - COMPLEX KEYS: since tokens can be string-encoded, we can use string-encoded tokens + * as the keys in JavaScript objects. However, after resolution, those string-encoded + * tokens could resolve to intrinsics (`{ Ref: ... }`), which CANNOT be stored in + * JavaScript objects anymore. + * + * We therefore need a protocol to store the resolved values somewhere in the JavaScript + * type model, which can be returned by `resolve()`, and interpreted by `tokenAwareStringify()` + * to produce the correct JSON. + * + * And example will quickly show the point: + * + * User writes: + * { [resource.resourceName]: 'SomeValue' } + * ------ string actually looks like ------> + * { '${Token[1234]}': 'SomeValue' } + * ------ resolve -------> + * { '$IntrinsicKey$0': [ {Ref: Resource}, 'SomeValue' ] } + * ------ tokenAwareStringify -------> + * '{ "' ++ { Ref: Resource } ++ '": "SomeValue" }' */ -function deepQuoteStringsForJSON(x: any): any { - if (typeof x === 'string') { - // Whenever we escape a string we strip off the outermost quotes - // since we're already in a quoted context. - const stringified = JSON.stringify(x); - return stringified.substring(1, stringified.length - 1); +function tokenAwareStringify(root: any, space: number, ctx: IResolveContext) { + let indent = 0; + + const ret = new Array(); + + // First completely resolve the tree, then encode to JSON while respecting the type + // hints we got for the resolved intrinsics. + recurse(ctx.resolve(root, { allowIntrinsicKeys: true })); + + switch (ret.length) { + case 0: return undefined; + case 1: return renderSegment(ret[0]); + default: + return fnJoinConcat(ret.map(renderSegment)); } - if (Array.isArray(x)) { - return x.map(deepQuoteStringsForJSON); + /** + * Stringify a JSON element + */ + function recurse(obj: any): void { + if (obj === undefined) { return; } + + if (Token.isUnresolved(obj)) { + throw new Error('This shouldnt happen anymore'); + } + if (Array.isArray(obj)) { + return renderCollection('[', ']', obj, recurse); + } + if (typeof obj === 'object' && obj != null && !(obj instanceof Date)) { + // Treat as an intrinsic if this LOOKS like a CFN intrinsic (`{ Ref: ... }`) + // AND it's the result of a token resolution. Otherwise, we just treat this + // value as a regular old JSON object (that happens to look a lot like an intrinsic). + if (isIntrinsic(obj) && resolvedTypeHint(obj)) { + return renderIntrinsic(obj); + } + + return renderCollection('{', '}', definedEntries(obj), ([key, value]) => { + if (key.startsWith(INTRINSIC_KEY_PREFIX)) { + [key, value] = value; + } + + recurse(key); + pushLiteral(prettyPunctuation(':')); + recurse(value); + }); + } + // Otherwise we have a scalar, defer to JSON.stringify()s serialization + pushLiteral(JSON.stringify(obj)); } - if (typeof x === 'object') { - for (const key of Object.keys(x)) { - x[key] = deepQuoteStringsForJSON(x[key]); + /** + * Render an object or list + */ + function renderCollection(pre: string, post: string, xs: Iterable, each: (x: A) => void) { + pushLiteral(pre); + indent += space; + let atLeastOne = false; + for (const [comma, item] of sepIter(xs)) { + if (comma) { pushLiteral(','); } + pushLineBreak(); + each(item); + atLeastOne = true; } + indent -= space; + if (atLeastOne) { pushLineBreak(); } + pushLiteral(post); } - return x; + function renderIntrinsic(intrinsic: any) { + switch (resolvedTypeHint(intrinsic)) { + case ResolutionTypeHint.STRING: + pushLiteral('"'); + pushIntrinsic(deepQuoteStringLiterals(intrinsic)); + pushLiteral('"'); + break; + + default: + pushIntrinsic(intrinsic); + break; + } + } + + /** + * Push a literal onto the current segment if it's also a literal, otherwise open a new Segment + */ + function pushLiteral(lit: string) { + let last = ret[ret.length - 1]; + if (last?.type !== 'literal') { + last = { type: 'literal', parts: [] }; + ret.push(last); + } + last.parts.push(lit); + } + + /** + * Add a new intrinsic segment + */ + function pushIntrinsic(intrinsic: any) { + ret.push({ type: 'intrinsic', intrinsic }); + } + + /** + * Push a line break if we are pretty-printing, otherwise don't + */ + function pushLineBreak() { + if (space > 0) { + pushLiteral(`\n${' '.repeat(indent)}`); + } + } + + /** + * Add a space after the punctuation if we are pretty-printing, no space if not + */ + function prettyPunctuation(punc: string) { + return space > 0 ? `${punc} ` : punc; + } +} + +/** + * A Segment is either a literal string or a CloudFormation intrinsic + */ +type Segment = { type: 'literal'; parts: string[] } | { type: 'intrinsic'; intrinsic: any }; + +/** + * Render a segment + */ +function renderSegment(s: Segment): NonNullable { + switch (s.type) { + case 'literal': return s.parts.join(''); + case 'intrinsic': return s.intrinsic; + } } const CLOUDFORMATION_CONCAT: IFragmentConcatenator = { @@ -204,3 +337,58 @@ export function isNameOfCloudFormationIntrinsic(name: string): boolean { // these are 'fake' intrinsics, only usable inside the parameter overrides of a CFN CodePipeline Action return name !== 'Fn::GetArtifactAtt' && name !== 'Fn::GetParam'; } + +/** + * Separated iterator + */ +function* sepIter(xs: Iterable): IterableIterator<[boolean, A]> { + let comma = false; + for (const item of xs) { + yield [comma, item]; + comma = true; + } +} + +/** + * Object.entries() but skipping undefined values + */ +function* definedEntries(xs: A): IterableIterator<[string, any]> { + for (const [key, value] of Object.entries(xs)) { + if (value !== undefined) { + yield [key, value]; + } + } +} + +/** + * Quote string literals inside an intrinsic + * + * Formally, this should only match string literals that will be interpreted as + * string literals. Fortunately, the strings that should NOT be quoted are + * Logical IDs and attribute names, which cannot contain quotes anyway. Hence, + * we can get away not caring about the distinction and just quoting everything. + */ +function deepQuoteStringLiterals(x: any): any { + if (Array.isArray(x)) { + return x.map(deepQuoteStringLiterals); + } + if (typeof x === 'object' && x != null) { + const ret: any = {}; + for (const [key, value] of Object.entries(x)) { + ret[deepQuoteStringLiterals(key)] = deepQuoteStringLiterals(value); + } + return ret; + } + if (typeof x === 'string') { + return quoteString(x); + } + return x; +} + +/** + * Quote the characters inside a string, for use inside toJSON + */ +function quoteString(s: string) { + s = JSON.stringify(s); + return s.substring(1, s.length - 1); +} \ No newline at end of file diff --git a/packages/@aws-cdk/core/lib/private/resolve.ts b/packages/@aws-cdk/core/lib/private/resolve.ts index d6ae73cdb8796..5f9620ecb759c 100644 --- a/packages/@aws-cdk/core/lib/private/resolve.ts +++ b/packages/@aws-cdk/core/lib/private/resolve.ts @@ -1,5 +1,5 @@ import { IConstruct } from 'constructs'; -import { DefaultTokenResolver, IPostProcessor, IResolvable, IResolveContext, ITokenResolver, StringConcat } from '../resolvable'; +import { DefaultTokenResolver, IPostProcessor, IResolvable, IResolveContext, ITokenResolver, ResolveChangeContextOptions, StringConcat } from '../resolvable'; import { TokenizedStringFragments } from '../string-fragments'; import { containsListTokenElement, TokenString, unresolved } from './encoding'; import { TokenMap } from './token-map'; @@ -9,9 +9,38 @@ import { TokenMap } from './token-map'; import { IConstruct as ICoreConstruct } from '../construct-compat'; // This file should not be exported to consumers, resolving should happen through Construct.resolve() - const tokenMap = TokenMap.instance(); +/** + * Resolved complex values will have a type hint applied. + * + * The type hint will be based on the type of the input value that was resolved. + * + * If the value was encoded, the type hint will be the type of the encoded value. In case + * of a plain `IResolvable`, a type hint of 'string' will be assumed. + */ +const RESOLUTION_TYPEHINT_SYM = Symbol.for('@aws-cdk/core.resolvedTypeHint'); + +/** + * Prefix used for intrinsic keys + * + * If a key with this prefix is found in an object, the actual value of the + * key doesn't matter. The value of this key will be an `[ actualKey, actualValue ]` + * tuple, and the `actualKey` will be a value which otherwise couldn't be represented + * in the types of `string | number | symbol`, which are the only possible JavaScript + * object keys. + */ +export const INTRINSIC_KEY_PREFIX = '$IntrinsicKey$'; + +/** + * Type hints for resolved values + */ +export enum ResolutionTypeHint { + STRING = 'string', + NUMBER = 'number', + LIST = 'list', +} + /** * Options to the resolve() operation * @@ -25,6 +54,36 @@ export interface IResolveOptions { preparing: boolean; resolver: ITokenResolver; prefix?: string[]; + + /** + * Whether or not to allow intrinsics in keys of an object + * + * Because keys of an object must be strings, a (resolved) intrinsic, which + * is an object, cannot be stored in that position. By default, we reject these + * intrinsics if we encounter them. + * + * If this is set to `true`, in order to store the complex value in a map, + * keys that happen to evaluate to intrinsics will be added with a unique key + * identified by an uncomming prefix, mapped to a tuple that represents the + * actual key/value-pair. The map will look like this: + * + * { + * '$IntrinsicKey$0': [ { Ref: ... }, 'value1' ], + * '$IntrinsicKey$1': [ { Ref: ... }, 'value2' ], + * 'regularKey': 'value3', + * ... + * } + * + * Callers should only set this option to `true` if they are prepared to deal with + * the object in this weird shape, and massage it back into a correct object afterwards. + * + * (A regular but uncommon string was chosen over something like symbols or + * other ways of tagging the extra values in order to simplify the implementation which + * maintains the desired behavior `resolve(resolve(x)) == resolve(x)`). + * + * @default false + */ + allowIntrinsicKeys?: boolean; } /** @@ -50,7 +109,7 @@ export function resolve(obj: any, options: IResolveOptions): any { preparing: options.preparing, scope: options.scope as ICoreConstruct, registerPostProcessor(pp) { postProcessor = pp; }, - resolve(x: any) { return resolve(x, { ...options, prefix: newPrefix }); }, + resolve(x: any, changeOptions?: ResolveChangeContextOptions) { return resolve(x, { ...options, ...changeOptions, prefix: newPrefix }); }, }; return [context, { postProcess(x) { return postProcessor ? postProcessor.postProcess(x, context) : x; } }]; @@ -98,7 +157,7 @@ export function resolve(obj: any, options: IResolveOptions): any { const str = TokenString.forString(obj); if (str.test()) { const fragments = str.split(tokenMap.lookupToken.bind(tokenMap)); - return options.resolver.resolveString(fragments, makeContext()[0]); + return tagResolvedValue(options.resolver.resolveString(fragments, makeContext()[0]), ResolutionTypeHint.STRING); } return obj; } @@ -107,7 +166,7 @@ export function resolve(obj: any, options: IResolveOptions): any { // number - potentially decode Tokenized number // if (typeof(obj) === 'number') { - return resolveNumberToken(obj, makeContext()[0]); + return tagResolvedValue(resolveNumberToken(obj, makeContext()[0]), ResolutionTypeHint.NUMBER); } // @@ -124,7 +183,7 @@ export function resolve(obj: any, options: IResolveOptions): any { if (Array.isArray(obj)) { if (containsListTokenElement(obj)) { - return options.resolver.resolveList(obj, makeContext()[0]); + return tagResolvedValue(options.resolver.resolveList(obj, makeContext()[0]), ResolutionTypeHint.LIST); } const arr = obj @@ -140,7 +199,8 @@ export function resolve(obj: any, options: IResolveOptions): any { if (unresolved(obj)) { const [context, postProcessor] = makeContext(); - return options.resolver.resolveToken(obj, context, postProcessor); + const ret = tagResolvedValue(options.resolver.resolveToken(obj, context, postProcessor), ResolutionTypeHint.STRING); + return ret; } // @@ -155,24 +215,40 @@ export function resolve(obj: any, options: IResolveOptions): any { } const result: any = { }; + let intrinsicKeyCtr = 0; for (const key of Object.keys(obj)) { - const resolvedKey = makeContext()[0].resolve(key); - if (typeof(resolvedKey) !== 'string') { - // eslint-disable-next-line max-len - throw new Error(`"${key}" is used as the key in a map so must resolve to a string, but it resolves to: ${JSON.stringify(resolvedKey)}. Consider using "CfnJson" to delay resolution to deployment-time`); - } - - const value = makeContext(key)[0].resolve(obj[key]); + const value = makeContext(String(key))[0].resolve(obj[key]); // skip undefined if (typeof(value) === 'undefined') { continue; } - result[resolvedKey] = value; + // Simple case -- not an unresolved key + if (!unresolved(key)) { + result[key] = value; + continue; + } + + const resolvedKey = makeContext()[0].resolve(key); + if (typeof(resolvedKey) === 'string') { + result[resolvedKey] = value; + } else { + if (!options.allowIntrinsicKeys) { + // eslint-disable-next-line max-len + throw new Error(`"${String(key)}" is used as the key in a map so must resolve to a string, but it resolves to: ${JSON.stringify(resolvedKey)}. Consider using "CfnJson" to delay resolution to deployment-time`); + } + + // Can't represent this object in a JavaScript key position, but we can store it + // in value position. Use a unique symbol as the key. + result[`${INTRINSIC_KEY_PREFIX}${intrinsicKeyCtr++}`] = [resolvedKey, value]; + } } - return result; + // Because we may be called to recurse on already resolved values (that already have type hints applied) + // and we just copied those values into a fresh object, be sure to retain any type hints. + const previousTypeHint = resolvedTypeHint(obj); + return previousTypeHint ? tagResolvedValue(result, previousTypeHint) : result; } /** @@ -222,3 +298,32 @@ function resolveNumberToken(x: number, context: IResolveContext): any { if (token === undefined) { return x; } return context.resolve(token); } + +/** + * Apply a type hint to a resolved value + * + * The type hint will only be applied to objects. + * + * These type hints are used for correct JSON-ification of intrinsic values. + */ +function tagResolvedValue(value: any, typeHint: ResolutionTypeHint): any { + if (typeof value !== 'object' || value == null) { return value; } + Object.defineProperty(value, RESOLUTION_TYPEHINT_SYM, { + value: typeHint, + configurable: true, + }); + return value; +} + +/** + * Return the type hint from the given value + * + * If the value is not a resolved value (i.e, the result of resolving a token), + * `undefined` will be returned. + * + * These type hints are used for correct JSON-ification of intrinsic values. + */ +export function resolvedTypeHint(value: any): ResolutionTypeHint | undefined { + if (typeof value !== 'object' || value == null) { return undefined; } + return value[RESOLUTION_TYPEHINT_SYM]; +} \ No newline at end of file diff --git a/packages/@aws-cdk/core/lib/private/token-map.ts b/packages/@aws-cdk/core/lib/private/token-map.ts index 2523037724ef0..1a5b0e1f29547 100644 --- a/packages/@aws-cdk/core/lib/private/token-map.ts +++ b/packages/@aws-cdk/core/lib/private/token-map.ts @@ -1,6 +1,6 @@ import { IResolvable } from '../resolvable'; import { TokenizedStringFragments } from '../string-fragments'; -import { Token } from '../token'; +import { isResolvableObject, Token } from '../token'; import { BEGIN_LIST_TOKEN_MARKER, BEGIN_STRING_TOKEN_MARKER, createTokenDouble, END_TOKEN_MARKER, extractTokenDouble, TokenString, VALID_KEY_CHARS, @@ -104,6 +104,7 @@ export class TokenMap { * Lookup a token from an encoded value */ public tokenFromEncoding(x: any): IResolvable | undefined { + if (isResolvableObject(x)) { return x; } if (typeof x === 'string') { return this.lookupString(x); } if (Array.isArray(x)) { return this.lookupList(x); } if (Token.isUnresolved(x)) { return x; } diff --git a/packages/@aws-cdk/core/lib/resolvable.ts b/packages/@aws-cdk/core/lib/resolvable.ts index 2ddbd544ffbbb..9004cd111bb33 100644 --- a/packages/@aws-cdk/core/lib/resolvable.ts +++ b/packages/@aws-cdk/core/lib/resolvable.ts @@ -20,7 +20,7 @@ export interface IResolveContext { /** * Resolve an inner object */ - resolve(x: any): any; + resolve(x: any, options?: ResolveChangeContextOptions): any; /** * Use this postprocessor after the entire token structure has been resolved @@ -28,6 +28,18 @@ export interface IResolveContext { registerPostProcessor(postProcessor: IPostProcessor): void; } +/** + * Options that can be changed while doing a recursive resolve + */ +export interface ResolveChangeContextOptions { + /** + * Change the 'allowIntrinsicKeys' option + * + * @default - Unchanged + */ + readonly allowIntrinsicKeys?: boolean; +} + /** * Interface for values that can be resolvable later * diff --git a/packages/@aws-cdk/core/test/cloudformation-json.test.ts b/packages/@aws-cdk/core/test/cloudformation-json.test.ts index e9d850eb178a2..cb96020e04904 100644 --- a/packages/@aws-cdk/core/test/cloudformation-json.test.ts +++ b/packages/@aws-cdk/core/test/cloudformation-json.test.ts @@ -1,12 +1,36 @@ -import { nodeunitShim, Test } from 'nodeunit-shim'; -import { App, CfnOutput, Fn, Lazy, Stack, Token } from '../lib'; +import { App, Aws, CfnOutput, Fn, IPostProcessor, IResolvable, IResolveContext, Lazy, Stack, Token } from '../lib'; import { Intrinsic } from '../lib/private/intrinsic'; import { evaluateCFN } from './evaluate-cfn'; -nodeunitShim({ - 'string tokens can be JSONified and JSONification can be reversed'(test: Test) { - const stack = new Stack(); +let app: App; +let stack: Stack; +beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack'); +}); + +test('JSONification of literals looks like JSON.stringify', () => { + const structure = { + undefinedProp: undefined, + nestedObject: { + prop1: undefined, + prop2: 'abc', + prop3: 42, + prop4: [1, 2, 3], + }, + }; + + expect(stack.resolve(stack.toJsonString(structure))).toEqual(JSON.stringify(structure)); + expect(stack.resolve(stack.toJsonString(structure, 2))).toEqual(JSON.stringify(structure, undefined, 2)); +}); + +test('JSONification of undefined leads to undefined', () => { + expect(stack.resolve(stack.toJsonString(undefined))).toEqual(undefined); +}); + +describe('tokens that return literals', () => { + test('string tokens can be JSONified and JSONification can be reversed', () => { for (const token of tokensThatResolveTo('woof woof')) { // GIVEN const fido = { name: 'Fido', speaks: token }; @@ -15,15 +39,11 @@ nodeunitShim({ const resolved = stack.resolve(stack.toJsonString(fido)); // THEN - test.deepEqual(evaluateCFN(resolved), '{"name":"Fido","speaks":"woof woof"}'); + expect(evaluateCFN(resolved)).toEqual('{"name":"Fido","speaks":"woof woof"}'); } + }); - test.done(); - }, - - 'string tokens can be embedded while being JSONified'(test: Test) { - const stack = new Stack(); - + test('string tokens can be embedded while being JSONified', () => { for (const token of tokensThatResolveTo('woof woof')) { // GIVEN const fido = { name: 'Fido', speaks: `deep ${token}` }; @@ -32,57 +52,104 @@ nodeunitShim({ const resolved = stack.resolve(stack.toJsonString(fido)); // THEN - test.deepEqual(evaluateCFN(resolved), '{"name":"Fido","speaks":"deep woof woof"}'); + expect(evaluateCFN(resolved)).toEqual('{"name":"Fido","speaks":"deep woof woof"}'); } + }); - test.done(); - }, - - 'constant string has correct amount of quotes applied'(test: Test) { - const stack = new Stack(); - + test('constant string has correct amount of quotes applied', () => { const inputString = 'Hello, "world"'; // WHEN const resolved = stack.resolve(stack.toJsonString(inputString)); // THEN - test.deepEqual(evaluateCFN(resolved), JSON.stringify(inputString)); - - test.done(); - }, + expect(evaluateCFN(resolved)).toEqual(JSON.stringify(inputString)); + }); - 'integer Tokens behave correctly in stringification and JSONification'(test: Test) { + test('integer Tokens behave correctly in stringification and JSONification', () => { // GIVEN - const stack = new Stack(); const num = new Intrinsic(1); const embedded = `the number is ${num}`; // WHEN - test.equal(evaluateCFN(stack.resolve(embedded)), 'the number is 1'); - test.equal(evaluateCFN(stack.resolve(stack.toJsonString({ embedded }))), '{"embedded":"the number is 1"}'); - test.equal(evaluateCFN(stack.resolve(stack.toJsonString({ num }))), '{"num":1}'); + expect(evaluateCFN(stack.resolve(embedded))).toEqual('the number is 1'); + expect(evaluateCFN(stack.resolve(stack.toJsonString({ embedded })))).toEqual('{"embedded":"the number is 1"}'); + expect(evaluateCFN(stack.resolve(stack.toJsonString({ num })))).toEqual('{"num":1}'); + }); + + test('String-encoded lazies do not have quotes applied if they return objects', () => { + // This is unfortunately crazy behavior, but we have some clients already taking a + // dependency on the fact that `Lazy.stringValue({ produce: () => [...some list...] })` + // does not apply quotes but just renders the list. + + // GIVEN + const someList = Lazy.stringValue({ produce: () => [1, 2, 3] as any }); + + // WHEN + expect(evaluateCFN(stack.resolve(stack.toJsonString({ someList })))).toEqual('{"someList":[1,2,3]}'); + }); + + test('Literal-resolving List Tokens do not have quotes applied', () => { + // GIVEN + const someList = Token.asList([1, 2, 3]); + + // WHEN + expect(evaluateCFN(stack.resolve(stack.toJsonString({ someList })))).toEqual('{"someList":[1,2,3]}'); + }); + + test('Intrinsic-resolving List Tokens do not have quotes applied', () => { + // GIVEN + const someList = Token.asList(new Intrinsic({ Ref: 'Thing' })); + + // WHEN + expect(stack.resolve(stack.toJsonString({ someList }))).toEqual({ + 'Fn::Join': ['', ['{"someList":', { Ref: 'Thing' }, '}']], + }); + }); - test.done(); - }, - 'tokens in strings survive additional TokenJSON.stringification()'(test: Test) { + test('tokens in strings survive additional TokenJSON.stringification()', () => { // GIVEN - const stack = new Stack(); for (const token of tokensThatResolveTo('pong!')) { // WHEN const stringified = stack.toJsonString(`ping? ${token}`); // THEN - test.equal(evaluateCFN(stack.resolve(stringified)), '"ping? pong!"'); + expect(evaluateCFN(stack.resolve(stringified))).toEqual('"ping? pong!"'); } + }); + + test('Doubly nested strings evaluate correctly in JSON context', () => { + // WHEN + const fidoSays = Lazy.stringValue({ produce: () => 'woof' }); + + // WHEN + const resolved = stack.resolve(stack.toJsonString({ + information: `Did you know that Fido says: ${fidoSays}`, + })); + + // THEN + expect(evaluateCFN(resolved)).toEqual('{"information":"Did you know that Fido says: woof"}'); + }); + + test('Quoted strings in embedded JSON context are escaped', () => { + // GIVEN + const fidoSays = Lazy.stringValue({ produce: () => '"woof"' }); - test.done(); - }, + // WHEN + const resolved = stack.resolve(stack.toJsonString({ + information: `Did you know that Fido says: ${fidoSays}`, + })); + + // THEN + expect(evaluateCFN(resolved)).toEqual('{"information":"Did you know that Fido says: \\"woof\\""}'); + }); - 'intrinsic Tokens embed correctly in JSONification'(test: Test) { +}); + +describe('tokens returning CloudFormation intrinsics', () => { + test('intrinsic Tokens embed correctly in JSONification', () => { // GIVEN - const stack = new Stack(); const bucketName = new Intrinsic({ Ref: 'MyBucket' }); // WHEN @@ -90,13 +157,10 @@ nodeunitShim({ // THEN const context = { MyBucket: 'TheName' }; - test.equal(evaluateCFN(resolved, context), '{"theBucket":"TheName"}'); - - test.done(); - }, + expect(evaluateCFN(resolved, context)).toEqual('{"theBucket":"TheName"}'); + }); - 'fake intrinsics are serialized to objects'(test: Test) { - const stack = new Stack(); + test('fake intrinsics are serialized to objects', () => { const fakeIntrinsics = new Intrinsic({ a: { 'Fn::GetArtifactAtt': { @@ -112,16 +176,13 @@ nodeunitShim({ }); const stringified = stack.toJsonString(fakeIntrinsics); - test.equal(evaluateCFN(stack.resolve(stringified)), + expect(evaluateCFN(stack.resolve(stringified))).toEqual( '{"a":{"Fn::GetArtifactAtt":{"key":"val"}},"b":{"Fn::GetParam":["val1","val2"]}}'); + }); - test.done(); - }, - - 'embedded string literals in intrinsics are escaped when calling TokenJSON.stringify()'(test: Test) { + test('embedded string literals in intrinsics are escaped when calling TokenJSON.stringify()', () => { // GIVEN - const stack = new Stack(); - const token = Fn.join('', ['Hello', 'This\nIs', 'Very "cool"']); + const token = Fn.join('', ['Hello ', Token.asString({ Ref: 'Planet' }), ', this\nIs', 'Very "cool"']); // WHEN const resolved = stack.resolve(stack.toJsonString({ @@ -130,15 +191,42 @@ nodeunitShim({ })); // THEN - const expected = '{"literal":"I can also \\"contain\\" quotes","token":"HelloThis\\nIsVery \\"cool\\""}'; - test.equal(evaluateCFN(resolved), expected); + const context = { Planet: 'World' }; + const expected = '{"literal":"I can also \\"contain\\" quotes","token":"Hello World, this\\nIsVery \\"cool\\""}'; + expect(evaluateCFN(resolved, context)).toEqual(expected); + }); - test.done(); - }, + test('embedded string literals are escaped in Fn.sub (implicit references)', () => { + // GIVEN + const token = Fn.sub('I am in account "${AWS::AccountId}"'); - 'Tokens in Tokens are handled correctly'(test: Test) { + // WHEN + const resolved = stack.resolve(stack.toJsonString({ token })); + + // THEN + const context = { 'AWS::AccountId': '1234' }; + const expected = '{"token":"I am in account \\"1234\\""}'; + expect(evaluateCFN(resolved, context)).toEqual(expected); + }); + + test('embedded string literals are escaped in Fn.sub (explicit references)', () => { + // GIVEN + const token = Fn.sub('I am in account "${Acct}", also wanted to say: ${Also}', { + Acct: Aws.ACCOUNT_ID, + Also: '"hello world"', + }); + + // WHEN + const resolved = stack.resolve(stack.toJsonString({ token })); + + // THEN + const context = { 'AWS::AccountId': '1234' }; + const expected = '{"token":"I am in account \\"1234\\", also wanted to say: \\"hello world\\""}'; + expect(evaluateCFN(resolved, context)).toEqual(expected); + }); + + test('Tokens in Tokens are handled correctly', () => { // GIVEN - const stack = new Stack(); const bucketName = new Intrinsic({ Ref: 'MyBucket' }); const combinedName = Fn.join('', ['The bucket name is ', bucketName.toString()]); @@ -147,14 +235,25 @@ nodeunitShim({ // THEN const context = { MyBucket: 'TheName' }; - test.equal(evaluateCFN(resolved, context), '{"theBucket":"The bucket name is TheName"}'); + expect(evaluateCFN(resolved, context)).toEqual('{"theBucket":"The bucket name is TheName"}'); + }); + + test('Intrinsics in postprocessors are handled correctly', () => { + // GIVEN + const bucketName = new Intrinsic({ Ref: 'MyBucket' }); + const combinedName = new DummyPostProcessor(['this', 'is', bucketName]); + + // WHEN + const resolved = stack.resolve(stack.toJsonString({ theBucket: combinedName })); - test.done(); - }, + // THEN + expect(resolved).toEqual({ + 'Fn::Join': ['', ['{"theBucket":["this","is","', { Ref: 'MyBucket' }, '"]}']], + }); + }); - 'Doubly nested strings evaluate correctly in JSON context'(test: Test) { + test('Doubly nested strings evaluate correctly in JSON context', () => { // WHEN - const stack = new Stack(); const fidoSays = Lazy.string({ produce: () => 'woof' }); // WHEN @@ -163,14 +262,11 @@ nodeunitShim({ })); // THEN - test.deepEqual(evaluateCFN(resolved), '{"information":"Did you know that Fido says: woof"}'); + expect(evaluateCFN(resolved)).toEqual('{"information":"Did you know that Fido says: woof"}'); + }); - test.done(); - }, - - 'Doubly nested intrinsics evaluate correctly in JSON context'(test: Test) { + test('Doubly nested intrinsics evaluate correctly in JSON context', () => { // GIVEN - const stack = new Stack(); const fidoSays = Lazy.any({ produce: () => ({ Ref: 'Something' }) }); // WHEN @@ -180,14 +276,10 @@ nodeunitShim({ // THEN const context = { Something: 'woof woof' }; - test.deepEqual(evaluateCFN(resolved, context), '{"information":"Did you know that Fido says: woof woof"}'); - - test.done(); - }, + expect(evaluateCFN(resolved, context)).toEqual('{"information":"Did you know that Fido says: woof woof"}'); + }); - 'Quoted strings in embedded JSON context are escaped'(test: Test) { - // GIVEN - const stack = new Stack(); + test('Nested strings are quoted correctly', () => { const fidoSays = Lazy.string({ produce: () => '"woof"' }); // WHEN @@ -196,14 +288,11 @@ nodeunitShim({ })); // THEN - test.deepEqual(evaluateCFN(resolved), '{"information":"Did you know that Fido says: \\"woof\\""}'); + expect(evaluateCFN(resolved)).toEqual('{"information":"Did you know that Fido says: \\"woof\\""}'); + }); - test.done(); - }, - - 'cross-stack references are also properly converted by toJsonString()'(test: Test) { + test('cross-stack references are also properly converted by toJsonString()', () => { // GIVEN - const app = new App(); const stack1 = new Stack(app, 'Stack1'); const stack2 = new Stack(app, 'Stack2'); @@ -217,7 +306,7 @@ nodeunitShim({ // THEN const asm = app.synth(); - test.deepEqual(asm.getStackByName('Stack2').template, { + expect(asm.getStackByName('Stack2').template).toEqual({ Outputs: { Stack1Id: { Value: { @@ -232,11 +321,40 @@ nodeunitShim({ }, }, }); + }); - test.done(); - }, + test('Intrinsics can occur in key position', () => { + // GIVEN + const bucketName = Token.asString({ Ref: 'MyBucket' }); - 'Every Token used inside a JSONified string is given an opportunity to be uncached'(test: Test) { + // WHEN + const resolved = stack.resolve(stack.toJsonString({ + [bucketName]: 'Is Cool', + [`${bucketName} Is`]: 'Cool', + })); + + // THEN + const context = { MyBucket: 'Harry' }; + expect(evaluateCFN(resolved, context)).toEqual('{"Harry":"Is Cool","Harry Is":"Cool"}'); + }); + + test('toJsonString() can be used recursively', () => { + // GIVEN + const bucketName = Token.asString({ Ref: 'MyBucket' }); + + // WHEN + const embeddedJson = stack.toJsonString({ message: `the bucket name is ${bucketName}` }); + const outerJson = stack.toJsonString({ embeddedJson }); + + // THEN + const evaluatedJson = evaluateCFN(stack.resolve(outerJson), { + MyBucket: 'Bucky', + }); + expect(evaluatedJson).toEqual('{"embeddedJson":"{\\"message\\":\\"the bucket name is Bucky\\"}"}'); + expect(JSON.parse(JSON.parse(evaluatedJson).embeddedJson).message).toEqual('the bucket name is Bucky'); + }); + + test('Every Token used inside a JSONified string is given an opportunity to be uncached', () => { // Check that tokens aren't accidentally fully resolved by the first invocation/resolution // of toJsonString(). On every evaluation, Tokens referenced inside the structure should be // given a chance to be either cached or uncached. @@ -244,10 +362,6 @@ nodeunitShim({ // (NOTE: This does not check whether the implementation of toJsonString() itself is cached or // not; that depends on aws/aws-cdk#11224 and should be done in a different PR). - // GIVEN - const app = new App(); - const stack = new Stack(app, 'Stack1'); - // WHEN let counter = 0; const counterString = Token.asString({ resolve: () => `${++counter}` }); @@ -256,11 +370,29 @@ nodeunitShim({ // THEN expect(stack.resolve(jsonString)).toEqual('{"counterString":"1"}'); expect(stack.resolve(jsonString)).toEqual('{"counterString":"2"}'); + }); +}); + +test('JSON strings nested inside JSON strings have correct quoting', () => { + // GIVEN + const payload = stack.toJsonString({ + message: Fn.sub('I am in account "${AWS::AccountId}"'), + }); + + // WHEN + const resolved = stack.resolve(stack.toJsonString({ payload })); + + // THEN + const context = { 'AWS::AccountId': '1234' }; + const expected = '{"payload":"{\\"message\\":\\"I am in account \\\\\\"1234\\\\\\"\\"}"}'; + const evaluated = evaluateCFN(resolved, context); + expect(evaluated).toEqual(expected); - test.done(); - }, + // Is this even correct? Let's ask JavaScript because I have trouble reading this many backslashes. + expect(JSON.parse(JSON.parse(evaluated).payload).message).toEqual('I am in account "1234"'); }); + /** * Return two Tokens, one of which evaluates to a Token directly, one which evaluates to it lazily */ @@ -270,3 +402,20 @@ function tokensThatResolveTo(value: any): Token[] { Lazy.any({ produce: () => value }), ]; } + +class DummyPostProcessor implements IResolvable, IPostProcessor { + public readonly creationStack: string[]; + + constructor(private readonly value: any) { + this.creationStack = ['test']; + } + + public resolve(context: IResolveContext) { + context.registerPostProcessor(this); + return context.resolve(this.value); + } + + public postProcess(o: any, _context: IResolveContext): any { + return o; + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/core/test/evaluate-cfn.ts b/packages/@aws-cdk/core/test/evaluate-cfn.ts index 6d60949cc3193..af07209c7e5a7 100644 --- a/packages/@aws-cdk/core/test/evaluate-cfn.ts +++ b/packages/@aws-cdk/core/test/evaluate-cfn.ts @@ -42,16 +42,8 @@ export function evaluateCFN(object: any, context: {[key: string]: string} = {}): return context[key]; }, - 'Fn::Sub'(argument: string | [string, Record]) { - let template; - let placeholders: Record; - if (Array.isArray(argument)) { - template = argument[0]; - placeholders = evaluate(argument[1]); - } else { - template = argument; - placeholders = context; - } + 'Fn::Sub'(template: string, explicitPlaceholders?: Record) { + const placeholders = explicitPlaceholders ? evaluate(explicitPlaceholders) : context; if (typeof template !== 'string') { throw new Error('The first argument to {Fn::Sub} must be a string literal (cannot be the result of an expression)'); @@ -79,7 +71,7 @@ export function evaluateCFN(object: any, context: {[key: string]: string} = {}): const ret: {[key: string]: any} = {}; for (const key of Object.keys(obj)) { - ret[key] = evaluateCFN(obj[key]); + ret[key] = evaluate(obj[key]); } return ret; } From e635dac49c66773cd82fd260e6747469728ffbc1 Mon Sep 17 00:00:00 2001 From: Adam Ruka Date: Wed, 10 Mar 2021 08:33:42 -0800 Subject: [PATCH 03/14] chore: change the parameter used for 'find' in link-all.sh (#13510) The parameter currently used for `find` in `link-all.sh`, `-perm /111`, fails on my Mac. Switch to using `-perm +111`, which works fine, and that's also what JSII uses in [its `link-all.sh` script](https://github.com/aws/jsii/blob/f8bde4a01bf7c707c87ab00748eeeb7632e7c820/scripts/link-all.sh#L26-L26). ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- link-all.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/link-all.sh b/link-all.sh index 6df7b1838ad56..03a94f7e12a05 100755 --- a/link-all.sh +++ b/link-all.sh @@ -26,7 +26,7 @@ for module in ${modules}; do # according to spec (we look in the bin/ directory instead of the { "scripts" # } entry in package.json but it's quite a bit easier. if [[ -d $module/bin ]]; then - for script in $(find $module/bin -perm /111); do + for script in $(find $module/bin -perm +111); do echo "${script} => node_modules/.bin/$(basename $script)" ln -fs ${script} node_modules/.bin done From 8d592ea89c0eda19329d5a31517522ec02ceb874 Mon Sep 17 00:00:00 2001 From: Darren Date: Wed, 10 Mar 2021 11:29:38 -0600 Subject: [PATCH 04/14] fix(iam): policy statement tries to validate tokens (#13493) Looking for guidance on error messaging and/or docs to update Fixes #13479 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-iam/lib/policy-statement.ts | 3 ++- .../@aws-cdk/aws-iam/test/policy-document.test.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/@aws-cdk/aws-iam/lib/policy-statement.ts b/packages/@aws-cdk/aws-iam/lib/policy-statement.ts index ce817a58e508e..78a588760c9d6 100644 --- a/packages/@aws-cdk/aws-iam/lib/policy-statement.ts +++ b/packages/@aws-cdk/aws-iam/lib/policy-statement.ts @@ -64,7 +64,8 @@ export class PolicyStatement { constructor(props: PolicyStatementProps = {}) { // Validate actions for (const action of [...props.actions || [], ...props.notActions || []]) { - if (!/^(\*|[a-zA-Z0-9-]+:[a-zA-Z0-9*]+)$/.test(action)) { + + if (!/^(\*|[a-zA-Z0-9-]+:[a-zA-Z0-9*]+)$/.test(action) && !cdk.Token.isUnresolved(action)) { throw new Error(`Action '${action}' is invalid. An action string consists of a service namespace, a colon, and the name of an action. Action names can include wildcards.`); } } diff --git a/packages/@aws-cdk/aws-iam/test/policy-document.test.ts b/packages/@aws-cdk/aws-iam/test/policy-document.test.ts index d8a9b1337c21c..bd3bd6fd31aa3 100644 --- a/packages/@aws-cdk/aws-iam/test/policy-document.test.ts +++ b/packages/@aws-cdk/aws-iam/test/policy-document.test.ts @@ -102,6 +102,19 @@ describe('IAM policy document', () => { }).toThrow(/Action 'in:val:id' is invalid/); }); + // https://github.com/aws/aws-cdk/issues/13479 + test('Does not validate unresolved tokens', () => { + const stack = new Stack(); + const perm = new PolicyStatement({ + actions: [`${Lazy.string({ produce: () => 'sqs:sendMessage' })}`], + }); + + expect(stack.resolve(perm.toStatementJson())).toEqual({ + Effect: 'Allow', + Action: 'sqs:sendMessage', + }); + }); + test('Cannot combine Resources and NotResources', () => { expect(() => { new PolicyStatement({ From 78b265cba23b438cb53655dee40b286852b9b3b7 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 10 Mar 2021 19:04:52 +0100 Subject: [PATCH 05/14] chore(aws-cdk-lib): change namespaces/package names in line with RFC 6 (#13494) Changes: * .NET: Namespace changed from `Amazon.CDK.Lib` -> `Amazon.CDK` (so `Stack` has the same FQN, same namespace as in Monocdk) * Java: Package name changed from `software.amazon.awscdk.lib` -> `software.amazon.awscdk.core` (so `Stack` has the same FQN, same namespace as in Monocdk) * Java: Changed artifact ID to match what's written in [RFC 6] * Python: Changed dist name to match what's written in [RFC 6] * Python: Change namespace to `aws_cdk` instead of `aws_cdk_lib` for minimal interference. Still need to test whether it's okay to change this to `aws_cdk.core` (like for Java) so `Stack` will keep the same FQN. Monocdk does something different for Python. [RFC 6]: https://github.com/aws/aws-cdk-rfcs/blob/master/text/0006-monolothic-packaging.md ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/aws-cdk-lib/package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/aws-cdk-lib/package.json b/packages/aws-cdk-lib/package.json index bd2ce09408acc..5380837857d8f 100644 --- a/packages/aws-cdk-lib/package.json +++ b/packages/aws-cdk-lib/package.json @@ -50,22 +50,22 @@ "outdir": "dist", "targets": { "dotnet": { - "namespace": "Amazon.CDK.Lib", + "namespace": "Amazon.CDK", "packageId": "Amazon.CDK.Lib", "iconUrl": "https://raw.githubusercontent.com/aws/aws-cdk/master/logo/default-256-dark.png", "versionSuffix": "-devpreview" }, "java": { - "package": "software.amazon.awscdk.lib", + "package": "software.amazon.awscdk.core", "maven": { "groupId": "software.amazon.awscdk", - "artifactId": "lib", + "artifactId": "aws-cdk-lib", "versionSuffix": ".DEVPREVIEW" } }, "python": { - "distName": "aws-cdk.lib", - "module": "aws_cdk.lib" + "distName": "aws-cdk-lib", + "module": "aws_cdk" } }, "projectReferences": false From cc608d055ffefb798ad6378ab07f36cb241897da Mon Sep 17 00:00:00 2001 From: Ayush Goyal Date: Thu, 11 Mar 2021 00:08:21 +0530 Subject: [PATCH 06/14] feat(stepfunctions-tasks): Support calling ApiGateway REST and HTTP APIs (#13033) feat(stepfunctions-tasks): Support calling APIGW REST and HTTP APIs Taking ownership of the original PR #11565 by @Sumeet-Badyal API as per documentation here: https://docs.aws.amazon.com/step-functions/latest/dg/connect-api-gateway.html https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-control-access-using-iam-policies-to-invoke-api.html closes #11566 closes #11565 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../aws-stepfunctions-tasks/README.md | 44 ++ .../lib/apigateway/base-types.ts | 79 ++++ .../lib/apigateway/base.ts | 69 +++ .../lib/apigateway/call-http-api.ts | 62 +++ .../lib/apigateway/call-rest-api.ts | 51 +++ .../lib/apigateway/index.ts | 3 + .../aws-stepfunctions-tasks/lib/index.ts | 1 + .../aws-stepfunctions-tasks/package.json | 6 + .../test/apigateway/call-http-api.test.ts | 145 +++++++ .../test/apigateway/call-rest-api.test.ts | 151 +++++++ .../integ.call-http-api.expected.json | 263 ++++++++++++ .../test/apigateway/integ.call-http-api.ts | 48 +++ .../integ.call-rest-api.expected.json | 394 ++++++++++++++++++ .../test/apigateway/integ.call-rest-api.ts | 43 ++ 14 files changed, 1359 insertions(+) create mode 100644 packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/base-types.ts create mode 100644 packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/base.ts create mode 100644 packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/call-http-api.ts create mode 100644 packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/call-rest-api.ts create mode 100644 packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/index.ts create mode 100644 packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/call-http-api.test.ts create mode 100644 packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/call-rest-api.test.ts create mode 100644 packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/integ.call-http-api.expected.json create mode 100644 packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/integ.call-http-api.ts create mode 100644 packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/integ.call-rest-api.expected.json create mode 100644 packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/integ.call-rest-api.ts diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/README.md b/packages/@aws-cdk/aws-stepfunctions-tasks/README.md index 69d97936aabcb..8d024db58e552 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/README.md +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/README.md @@ -28,6 +28,9 @@ This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aw - [ResultPath](#resultpath) - [Parameters](#task-parameters-from-the-state-json) - [Evaluate Expression](#evaluate-expression) +- [API Gateway](#api-gateway) + - [Call REST API Endpoint](#call-rest-api-endpoint) + - [Call HTTP API Endpoint](#call-http-api-endpoint) - [Athena](#athena) - [StartQueryExecution](#startQueryExecution) - [GetQueryExecution](#getQueryExecution) @@ -217,6 +220,47 @@ The `EvaluateExpression` supports a `runtime` prop to specify the Lambda runtime to use to evaluate the expression. Currently, only runtimes of the Node.js family are supported. +## API Gateway + +Step Functions supports [API Gateway](https://docs.aws.amazon.com/step-functions/latest/dg/connect-api-gateway.html) through the service integration pattern. + +HTTP APIs are designed for low-latency, cost-effective integrations with AWS services, including AWS Lambda, and HTTP endpoints. +HTTP APIs support OIDC and OAuth 2.0 authorization, and come with built-in support for CORS and automatic deployments. +Previous-generation REST APIs currently offer more features. More details can be found [here](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-vs-rest.html). + +### Call REST API Endpoint + +The `CallApiGatewayRestApiEndpoint` calls the REST API endpoint. + +```ts +import * as sfn from '@aws-cdk/aws-stepfunctions'; +import * as tasks from `@aws-cdk/aws-stepfunctions-tasks`; + +const restApi = new apigateway.RestApi(stack, 'MyRestApi'); + +const invokeTask = new tasks.CallApiGatewayRestApiEndpoint(stack, 'Call REST API', { + api: restApi, + stageName: 'prod', + method: HttpMethod.GET, +}); +``` + +### Call HTTP API Endpoint + +The `CallApiGatewayHttpApiEndpoint` calls the HTTP API endpoint. + +```ts +import * as sfn from '@aws-cdk/aws-stepfunctions'; +import * as tasks from `@aws-cdk/aws-stepfunctions-tasks`; + +const httpApi = new apigatewayv2.HttpApi(stack, 'MyHttpApi'); + +const invokeTask = new tasks.CallApiGatewayHttpApiEndpoint(stack, 'Call HTTP API', { + api: httpApi, + method: HttpMethod.GET, +}); +``` + ## Athena Step Functions supports [Athena](https://docs.aws.amazon.com/step-functions/latest/dg/connect-athena.html) through the service integration pattern. diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/base-types.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/base-types.ts new file mode 100644 index 0000000000000..64c649063e57c --- /dev/null +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/base-types.ts @@ -0,0 +1,79 @@ +import * as sfn from '@aws-cdk/aws-stepfunctions'; + +/** Http Methods that API Gateway supports */ +export enum HttpMethod { + /** Retreive data from a server at the specified resource */ + GET = 'GET', + + /** Send data to the API endpoint to create or udpate a resource */ + POST = 'POST', + + /** Send data to the API endpoint to update or create a resource */ + PUT = 'PUT', + + /** Delete the resource at the specified endpoint */ + DELETE = 'DELETE', + + /** Apply partial modifications to the resource */ + PATCH = 'PATCH', + + /** Retreive data from a server at the specified resource without the response body */ + HEAD = 'HEAD', + + /** Return data describing what other methods and operations the server supports */ + OPTIONS = 'OPTIONS' +} + +/** + * The authentication method used to call the endpoint + */ +export enum AuthType { + /** Call the API direclty with no authorization method */ + NO_AUTH = 'NO_AUTH', + + /** Use the IAM role associated with the current state machine for authorization */ + IAM_ROLE = 'IAM_ROLE', + + /** Use the resource policy of the API for authorization */ + RESOURCE_POLICY = 'RESOURCE_POLICY', +} + +/** + * Base CallApiGatewayEdnpoint Task Props + */ +export interface CallApiGatewayEndpointBaseProps extends sfn.TaskStateBaseProps { + /** + * Http method for the API + */ + readonly method: HttpMethod; + + /** + * HTTP request information that does not relate to contents of the request + * @default - No headers + */ + readonly headers?: sfn.TaskInput; + + /** + * Path parameters appended after API endpoint + * @default - No path + */ + readonly apiPath?: string; + + /** + * Query strings attatched to end of request + * @default - No query parameters + */ + readonly queryParameters?: sfn.TaskInput; + + /** + * HTTP Request body + * @default - No request body + */ + readonly requestBody?: sfn.TaskInput; + + /** + * Authentication methods + * @default AuthType.NO_AUTH + */ + readonly authType?: AuthType; +} diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/base.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/base.ts new file mode 100644 index 0000000000000..edce3aa0f627c --- /dev/null +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/base.ts @@ -0,0 +1,69 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as sfn from '@aws-cdk/aws-stepfunctions'; +import { Construct } from 'constructs'; +import { integrationResourceArn, validatePatternSupported } from '../private/task-utils'; +import { AuthType, CallApiGatewayEndpointBaseProps } from './base-types'; + +/** + * Base CallApiGatewayEndpoint Task + * @internal + */ +export abstract class CallApiGatewayEndpointBase extends sfn.TaskStateBase { + private static readonly SUPPORTED_INTEGRATION_PATTERNS: sfn.IntegrationPattern[] = [ + sfn.IntegrationPattern.REQUEST_RESPONSE, + sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN, + ]; + + private readonly baseProps: CallApiGatewayEndpointBaseProps; + private readonly integrationPattern: sfn.IntegrationPattern; + + protected abstract readonly apiEndpoint: string; + protected abstract readonly arnForExecuteApi: string; + protected abstract readonly stageName?: string; + + constructor(scope: Construct, id: string, props: CallApiGatewayEndpointBaseProps) { + super(scope, id, props); + + this.baseProps = props; + this.integrationPattern = props.integrationPattern ?? sfn.IntegrationPattern.REQUEST_RESPONSE; + validatePatternSupported(this.integrationPattern, CallApiGatewayEndpointBase.SUPPORTED_INTEGRATION_PATTERNS); + + if (this.integrationPattern === sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN) { + if (!sfn.FieldUtils.containsTaskToken(this.baseProps.headers)) { + throw new Error('Task Token is required in `headers` for WAIT_FOR_TASK_TOKEN pattern. Use JsonPath.taskToken to set the token.'); + } + } + } + + /** + * @internal + */ + protected _renderTask() { + return { + Resource: integrationResourceArn('apigateway', 'invoke', this.integrationPattern), + Parameters: sfn.FieldUtils.renderObject({ + ApiEndpoint: this.apiEndpoint, + Method: this.baseProps.method, + Headers: this.baseProps.headers?.value, + Stage: this.stageName, + Path: this.baseProps.apiPath, + QueryParameters: this.baseProps.queryParameters?.value, + RequestBody: this.baseProps.requestBody?.value, + AuthType: this.baseProps.authType ? this.baseProps.authType : 'NO_AUTH', + }), + }; + } + + protected createPolicyStatements(): iam.PolicyStatement[] { + if (this.baseProps.authType === AuthType.NO_AUTH) { + return []; + } + + return [ + new iam.PolicyStatement({ + resources: [this.arnForExecuteApi], + actions: ['execute-api:Invoke'], + }), + ]; + } +} diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/call-http-api.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/call-http-api.ts new file mode 100644 index 0000000000000..e06e46c2580b0 --- /dev/null +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/call-http-api.ts @@ -0,0 +1,62 @@ +import * as apigatewayv2 from '@aws-cdk/aws-apigatewayv2'; +import * as iam from '@aws-cdk/aws-iam'; +import * as sfn from '@aws-cdk/aws-stepfunctions'; +import * as cdk from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CallApiGatewayEndpointBase } from './base'; +import { CallApiGatewayEndpointBaseProps } from './base-types'; + +/** + * Properties for calling an HTTP API Endpoint + */ +export interface CallApiGatewayHttpApiEndpointProps extends CallApiGatewayEndpointBaseProps { + /** + * API to call + */ + readonly api: apigatewayv2.IHttpApi; + + /** + * Name of the stage where the API is deployed to in API Gateway + * @default '$default' + */ + readonly stageName?: string; +} + +/** + * Call HTTP API endpoint as a Task + * + * @see https://docs.aws.amazon.com/step-functions/latest/dg/connect-api-gateway.html + */ +export class CallApiGatewayHttpApiEndpoint extends CallApiGatewayEndpointBase { + protected readonly taskMetrics?: sfn.TaskMetricsConfig | undefined; + protected readonly taskPolicies?: iam.PolicyStatement[] | undefined; + + protected readonly apiEndpoint: string; + protected readonly arnForExecuteApi: string; + protected readonly stageName?: string; + + constructor(scope: Construct, id: string, private readonly props: CallApiGatewayHttpApiEndpointProps) { + super(scope, id, props); + + this.apiEndpoint = this.getApiEndpoint(); + this.arnForExecuteApi = this.getArnForExecuteApi(); + + this.taskPolicies = this.createPolicyStatements(); + } + + private getApiEndpoint(): string { + const apiStack = cdk.Stack.of(this.props.api); + return `${this.props.api.apiId}.execute-api.${apiStack.region}.${apiStack.urlSuffix}`; + } + + private getArnForExecuteApi(): string { + const { api, stageName, method, apiPath } = this.props; + + return cdk.Stack.of(api).formatArn({ + service: 'execute-api', + resource: api.apiId, + sep: '/', + resourceName: `${stageName}/${method}${apiPath}`, + }); + } +} diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/call-rest-api.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/call-rest-api.ts new file mode 100644 index 0000000000000..0352777e9c06a --- /dev/null +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/call-rest-api.ts @@ -0,0 +1,51 @@ +import * as apigateway from '@aws-cdk/aws-apigateway'; +import * as iam from '@aws-cdk/aws-iam'; +import * as sfn from '@aws-cdk/aws-stepfunctions'; +import * as cdk from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CallApiGatewayEndpointBase } from './base'; +import { CallApiGatewayEndpointBaseProps } from './base-types'; + +/** + * Properties for calling an REST API Endpoint + */ +export interface CallApiGatewayRestApiEndpointProps extends CallApiGatewayEndpointBaseProps { + /** + * API to call + */ + readonly api: apigateway.IRestApi; + + /** + * Name of the stage where the API is deployed to in API Gateway + */ + readonly stageName: string; +} + +/** + * Call REST API endpoint as a Task + * + * @see https://docs.aws.amazon.com/step-functions/latest/dg/connect-api-gateway.html + */ +export class CallApiGatewayRestApiEndpoint extends CallApiGatewayEndpointBase { + protected readonly taskMetrics?: sfn.TaskMetricsConfig | undefined; + protected readonly taskPolicies?: iam.PolicyStatement[] | undefined; + + protected readonly apiEndpoint: string; + protected readonly arnForExecuteApi: string; + protected readonly stageName?: string; + + constructor(scope: Construct, id: string, private readonly props: CallApiGatewayRestApiEndpointProps) { + super(scope, id, props); + + this.apiEndpoint = this.getApiEndpoint(); + this.arnForExecuteApi = props.api.arnForExecuteApi(props.method, props.apiPath, props.stageName); + this.stageName = props.stageName; + + this.taskPolicies = this.createPolicyStatements(); + } + + private getApiEndpoint(): string { + const apiStack = cdk.Stack.of(this.props.api); + return `${this.props.api.restApiId}.execute-api.${apiStack.region}.${apiStack.urlSuffix}`; + } +} diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/index.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/index.ts new file mode 100644 index 0000000000000..3d82ca2e7d548 --- /dev/null +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/apigateway/index.ts @@ -0,0 +1,3 @@ +export * from './base-types'; +export * from './call-rest-api'; +export * from './call-http-api'; diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/index.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/index.ts index 32e684f6d1adf..7b566bbbe4dad 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/index.ts +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/index.ts @@ -45,3 +45,4 @@ export * from './athena/get-query-execution'; export * from './athena/get-query-results'; export * from './databrew/start-job-run'; export * from './eks/call'; +export * from './apigateway'; diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/package.json b/packages/@aws-cdk/aws-stepfunctions-tasks/package.json index a6137f570b1a3..b18cd8fc7704c 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/package.json +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/package.json @@ -72,6 +72,9 @@ "pkglint": "0.0.0" }, "dependencies": { + "@aws-cdk/aws-apigateway": "0.0.0", + "@aws-cdk/aws-apigatewayv2": "0.0.0", + "@aws-cdk/aws-apigatewayv2-integrations": "0.0.0", "@aws-cdk/aws-batch": "0.0.0", "@aws-cdk/aws-cloudwatch": "0.0.0", "@aws-cdk/aws-codebuild": "0.0.0", @@ -95,6 +98,9 @@ }, "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { + "@aws-cdk/aws-apigateway": "0.0.0", + "@aws-cdk/aws-apigatewayv2": "0.0.0", + "@aws-cdk/aws-apigatewayv2-integrations": "0.0.0", "@aws-cdk/aws-batch": "0.0.0", "@aws-cdk/aws-cloudwatch": "0.0.0", "@aws-cdk/aws-codebuild": "0.0.0", diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/call-http-api.test.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/call-http-api.test.ts new file mode 100644 index 0000000000000..0e7a2cf616b9a --- /dev/null +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/call-http-api.test.ts @@ -0,0 +1,145 @@ +import * as apigatewayv2 from '@aws-cdk/aws-apigatewayv2'; +import * as sfn from '@aws-cdk/aws-stepfunctions'; +import * as cdk from '@aws-cdk/core'; +import { HttpMethod, CallApiGatewayHttpApiEndpoint } from '../../lib'; + +describe('CallApiGatewayHttpApiEndpoint', () => { + test('default', () => { + // GIVEN + const stack = new cdk.Stack(); + const httpApi = new apigatewayv2.HttpApi(stack, 'HttpApi'); + + // WHEN + const task = new CallApiGatewayHttpApiEndpoint(stack, 'Call', { + api: httpApi, + method: HttpMethod.GET, + }); + + // THEN + expect(stack.resolve(task.toStateJson())).toEqual({ + Type: 'Task', + End: true, + Parameters: { + ApiEndpoint: { + 'Fn::Join': [ + '', + [ + { + Ref: 'HttpApiF5A9A8A7', + }, + '.execute-api.', + { + Ref: 'AWS::Region', + }, + '.', + { + Ref: 'AWS::URLSuffix', + }, + ], + ], + }, + AuthType: 'NO_AUTH', + Method: 'GET', + }, + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':states:::apigateway:invoke', + ], + ], + }, + }); + }); + + test('wait for task token', () => { + // GIVEN + const stack = new cdk.Stack(); + const httpApi = new apigatewayv2.HttpApi(stack, 'HttpApi'); + + // WHEN + const task = new CallApiGatewayHttpApiEndpoint(stack, 'Call', { + api: httpApi, + method: HttpMethod.GET, + integrationPattern: sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN, + headers: sfn.TaskInput.fromObject({ TaskToken: sfn.JsonPath.taskToken }), + }); + + // THEN + expect(stack.resolve(task.toStateJson())).toEqual({ + Type: 'Task', + End: true, + Parameters: { + ApiEndpoint: { + 'Fn::Join': [ + '', + [ + { + Ref: 'HttpApiF5A9A8A7', + }, + '.execute-api.', + { + Ref: 'AWS::Region', + }, + '.', + { + Ref: 'AWS::URLSuffix', + }, + ], + ], + }, + AuthType: 'NO_AUTH', + Headers: { + 'TaskToken.$': '$$.Task.Token', + }, + Method: 'GET', + }, + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':states:::apigateway:invoke.waitForTaskToken', + ], + ], + }, + }); + }); + + test('wait for task token - missing token', () => { + // GIVEN + const stack = new cdk.Stack(); + const httpApi = new apigatewayv2.HttpApi(stack, 'HttpApi'); + + // THEN + expect(() => { + new CallApiGatewayHttpApiEndpoint(stack, 'Call', { + api: httpApi, + method: HttpMethod.GET, + integrationPattern: sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN, + }); + }).toThrow(/Task Token is required in `headers` for WAIT_FOR_TASK_TOKEN pattern. Use JsonPath.taskToken to set the token./); + }); + + test('unsupported integration pattern - RUN_JOB', () => { + // GIVEN + const stack = new cdk.Stack(); + const httpApi = new apigatewayv2.HttpApi(stack, 'HttpApi'); + + // THEN + expect(() => { + new CallApiGatewayHttpApiEndpoint(stack, 'Call', { + api: httpApi, + method: HttpMethod.GET, + integrationPattern: sfn.IntegrationPattern.RUN_JOB, + }); + }).toThrow(/Unsupported service integration pattern./); + }); +}); diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/call-rest-api.test.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/call-rest-api.test.ts new file mode 100644 index 0000000000000..37a083fb2cc95 --- /dev/null +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/call-rest-api.test.ts @@ -0,0 +1,151 @@ +import * as apigateway from '@aws-cdk/aws-apigateway'; +import * as sfn from '@aws-cdk/aws-stepfunctions'; +import * as cdk from '@aws-cdk/core'; +import { HttpMethod, CallApiGatewayRestApiEndpoint } from '../../lib'; + +describe('CallApiGatewayRestApiEndpoint', () => { + test('default', () => { + // GIVEN + const stack = new cdk.Stack(); + const restApi = new apigateway.RestApi(stack, 'RestApi'); + + // WHEN + const task = new CallApiGatewayRestApiEndpoint(stack, 'Call', { + api: restApi, + method: HttpMethod.GET, + stageName: 'dev', + }); + + // THEN + expect(stack.resolve(task.toStateJson())).toEqual({ + Type: 'Task', + End: true, + Parameters: { + ApiEndpoint: { + 'Fn::Join': [ + '', + [ + { + Ref: 'RestApi0C43BF4B', + }, + '.execute-api.', + { + Ref: 'AWS::Region', + }, + '.', + { + Ref: 'AWS::URLSuffix', + }, + ], + ], + }, + AuthType: 'NO_AUTH', + Method: 'GET', + Stage: 'dev', + }, + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':states:::apigateway:invoke', + ], + ], + }, + }); + }); + + test('wait for task token', () => { + // GIVEN + const stack = new cdk.Stack(); + const restApi = new apigateway.RestApi(stack, 'RestApi'); + + // WHEN + const task = new CallApiGatewayRestApiEndpoint(stack, 'Call', { + api: restApi, + method: HttpMethod.GET, + stageName: 'dev', + integrationPattern: sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN, + headers: sfn.TaskInput.fromObject({ TaskToken: sfn.JsonPath.taskToken }), + }); + + // THEN + expect(stack.resolve(task.toStateJson())).toEqual({ + Type: 'Task', + End: true, + Parameters: { + ApiEndpoint: { + 'Fn::Join': [ + '', + [ + { + Ref: 'RestApi0C43BF4B', + }, + '.execute-api.', + { + Ref: 'AWS::Region', + }, + '.', + { + Ref: 'AWS::URLSuffix', + }, + ], + ], + }, + AuthType: 'NO_AUTH', + Headers: { + 'TaskToken.$': '$$.Task.Token', + }, + Method: 'GET', + Stage: 'dev', + }, + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':states:::apigateway:invoke.waitForTaskToken', + ], + ], + }, + }); + }); + + test('wait for task token - missing token', () => { + // GIVEN + const stack = new cdk.Stack(); + const restApi = new apigateway.RestApi(stack, 'RestApi'); + + // THEN + expect(() => { + new CallApiGatewayRestApiEndpoint(stack, 'Call', { + api: restApi, + method: HttpMethod.GET, + stageName: 'dev', + integrationPattern: sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN, + }); + }).toThrow(/Task Token is required in `headers` for WAIT_FOR_TASK_TOKEN pattern. Use JsonPath.taskToken to set the token./); + }); + + test('unsupported integration pattern - RUN_JOB', () => { + // GIVEN + const stack = new cdk.Stack(); + const restApi = new apigateway.RestApi(stack, 'RestApi'); + + // THEN + expect(() => { + new CallApiGatewayRestApiEndpoint(stack, 'Call', { + api: restApi, + method: HttpMethod.GET, + stageName: 'dev', + integrationPattern: sfn.IntegrationPattern.RUN_JOB, + }); + }).toThrow(/Unsupported service integration pattern./); + }); +}); diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/integ.call-http-api.expected.json b/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/integ.call-http-api.expected.json new file mode 100644 index 0000000000000..6afe44cfecda5 --- /dev/null +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/integ.call-http-api.expected.json @@ -0,0 +1,263 @@ +{ + "Resources": { + "MyHttpApi8AEAAC21": { + "Type": "AWS::ApiGatewayV2::Api", + "Properties": { + "Name": "MyHttpApi", + "ProtocolType": "HTTP" + } + }, + "MyHttpApiDefaultStageDCB9BC49": { + "Type": "AWS::ApiGatewayV2::Stage", + "Properties": { + "ApiId": { + "Ref": "MyHttpApi8AEAAC21" + }, + "StageName": "$default", + "AutoDeploy": true + } + }, + "MyHttpApiANYCallHttpApiIntegMyHttpApiANY7E6F12A3Permission59116CA6": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "HelloHandler2E4FBA4D", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "MyHttpApi8AEAAC21" + }, + "/*/*/" + ] + ] + } + } + }, + "MyHttpApiANYHttpIntegration71abbf75d6f8e5ea93ec2120c0d78b754BBCECF5": { + "Type": "AWS::ApiGatewayV2::Integration", + "Properties": { + "ApiId": { + "Ref": "MyHttpApi8AEAAC21" + }, + "IntegrationType": "AWS_PROXY", + "IntegrationUri": { + "Fn::GetAtt": [ + "HelloHandler2E4FBA4D", + "Arn" + ] + }, + "PayloadFormatVersion": "2.0" + } + }, + "MyHttpApiANYC3543576": { + "Type": "AWS::ApiGatewayV2::Route", + "Properties": { + "ApiId": { + "Ref": "MyHttpApi8AEAAC21" + }, + "RouteKey": "ANY /", + "AuthorizationScopes": [], + "Target": { + "Fn::Join": [ + "", + [ + "integrations/", + { + "Ref": "MyHttpApiANYHttpIntegration71abbf75d6f8e5ea93ec2120c0d78b754BBCECF5" + } + ] + ] + } + } + }, + "HelloHandlerServiceRole11EF7C63": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "HelloHandler2E4FBA4D": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = async function(event, context) { return { statusCode: 200, body: \"hello, world!\" }; };" + }, + "Role": { + "Fn::GetAtt": [ + "HelloHandlerServiceRole11EF7C63", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs10.x" + }, + "DependsOn": [ + "HelloHandlerServiceRole11EF7C63" + ] + }, + "StateMachineRoleB840431D": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "states.", + { + "Ref": "AWS::Region" + }, + ".amazonaws.com" + ] + ] + } + } + } + ], + "Version": "2012-10-17" + } + } + }, + "StateMachineRoleDefaultPolicyDF1E6607": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "execute-api:Invoke", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "MyHttpApi8AEAAC21" + }, + "/undefined/GETundefined" + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "StateMachineRoleDefaultPolicyDF1E6607", + "Roles": [ + { + "Ref": "StateMachineRoleB840431D" + } + ] + } + }, + "StateMachine2E01A3A5": { + "Type": "AWS::StepFunctions::StateMachine", + "Properties": { + "RoleArn": { + "Fn::GetAtt": [ + "StateMachineRoleB840431D", + "Arn" + ] + }, + "DefinitionString": { + "Fn::Join": [ + "", + [ + "{\"StartAt\":\"Call APIGW\",\"States\":{\"Call APIGW\":{\"End\":true,\"Type\":\"Task\",\"OutputPath\":\"$.ResponseBody\",\"Resource\":\"arn:", + { + "Ref": "AWS::Partition" + }, + ":states:::apigateway:invoke\",\"Parameters\":{\"ApiEndpoint\":\"", + { + "Ref": "MyHttpApi8AEAAC21" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "\",\"Method\":\"GET\",\"AuthType\":\"IAM_ROLE\"}}},\"TimeoutSeconds\":30}" + ] + ] + } + }, + "DependsOn": [ + "StateMachineRoleDefaultPolicyDF1E6607", + "StateMachineRoleB840431D" + ] + } + }, + "Outputs": { + "stateMachineArn": { + "Value": { + "Ref": "StateMachine2E01A3A5" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/integ.call-http-api.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/integ.call-http-api.ts new file mode 100644 index 0000000000000..4eb1f3b896e92 --- /dev/null +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/integ.call-http-api.ts @@ -0,0 +1,48 @@ +import * as apigatewayv2 from '@aws-cdk/aws-apigatewayv2'; +import * as integrations from '@aws-cdk/aws-apigatewayv2-integrations'; +import * as lambda from '@aws-cdk/aws-lambda'; +import * as sfn from '@aws-cdk/aws-stepfunctions'; +import * as cdk from '@aws-cdk/core'; +import { AuthType, HttpMethod, CallApiGatewayHttpApiEndpoint } from '../../lib'; + +/* + * Stack verification steps: + * * aws stepfunctions start-execution --state-machine-arn : should return execution arn + * + * * aws stepfunctions describe-execution --execution-arn --query 'status': should return status as SUCCEEDED + * * aws stepfunctions describe-execution --execution-arn --query 'output': should return the string \"hello, world!\" + */ + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'CallHttpApiInteg'); +const httpApi = new apigatewayv2.HttpApi(stack, 'MyHttpApi'); + +const handler = new lambda.Function(stack, 'HelloHandler', { + runtime: lambda.Runtime.NODEJS_10_X, + handler: 'index.handler', + code: new lambda.InlineCode('exports.handler = async function(event, context) { return { statusCode: 200, body: "hello, world!" }; };'), +}); +httpApi.addRoutes({ + path: '/', + integration: new integrations.LambdaProxyIntegration({ + handler, + }), +}); + +const callEndpointJob = new CallApiGatewayHttpApiEndpoint(stack, 'Call APIGW', { + api: httpApi, + method: HttpMethod.GET, + authType: AuthType.IAM_ROLE, + outputPath: sfn.JsonPath.stringAt('$.ResponseBody'), +}); + +const chain = sfn.Chain.start(callEndpointJob); + +const sm = new sfn.StateMachine(stack, 'StateMachine', { + definition: chain, + timeout: cdk.Duration.seconds(30), +}); + +new cdk.CfnOutput(stack, 'stateMachineArn', { + value: sm.stateMachineArn, +}); diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/integ.call-rest-api.expected.json b/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/integ.call-rest-api.expected.json new file mode 100644 index 0000000000000..5970499935354 --- /dev/null +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/integ.call-rest-api.expected.json @@ -0,0 +1,394 @@ +{ + "Resources": { + "MyRestApi2D1F47A9": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "MyRestApi" + } + }, + "MyRestApiCloudWatchRoleD4042E8E": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "apigateway.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" + ] + ] + } + ] + } + }, + "MyRestApiAccount2FB6DB7A": { + "Type": "AWS::ApiGateway::Account", + "Properties": { + "CloudWatchRoleArn": { + "Fn::GetAtt": [ + "MyRestApiCloudWatchRoleD4042E8E", + "Arn" + ] + } + }, + "DependsOn": [ + "MyRestApi2D1F47A9" + ] + }, + "MyRestApiDeploymentB555B582d61dc696e12272a0706c826196fa8d62": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "RestApiId": { + "Ref": "MyRestApi2D1F47A9" + }, + "Description": "Automatically created by the RestApi construct" + }, + "DependsOn": [ + "MyRestApiANY05143F93" + ] + }, + "MyRestApiDeploymentStageprodC33B8E5F": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "RestApiId": { + "Ref": "MyRestApi2D1F47A9" + }, + "DeploymentId": { + "Ref": "MyRestApiDeploymentB555B582d61dc696e12272a0706c826196fa8d62" + }, + "StageName": "prod" + } + }, + "MyRestApiANYApiPermissionCallRestApiIntegMyRestApiB570839CANY0C27C1E3": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "Hello4A628BD4", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "MyRestApi2D1F47A9" + }, + "/", + { + "Ref": "MyRestApiDeploymentStageprodC33B8E5F" + }, + "/*/" + ] + ] + } + } + }, + "MyRestApiANYApiPermissionTestCallRestApiIntegMyRestApiB570839CANY379723EF": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "Hello4A628BD4", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "MyRestApi2D1F47A9" + }, + "/test-invoke-stage/*/" + ] + ] + } + } + }, + "MyRestApiANY05143F93": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "ResourceId": { + "Fn::GetAtt": [ + "MyRestApi2D1F47A9", + "RootResourceId" + ] + }, + "RestApiId": { + "Ref": "MyRestApi2D1F47A9" + }, + "AuthorizationType": "NONE", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "Hello4A628BD4", + "Arn" + ] + }, + "/invocations" + ] + ] + } + } + } + }, + "HelloServiceRole1E55EA16": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "Hello4A628BD4": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = async function(event, context) { return { statusCode: 200, body: \"hello, world!\" }; };" + }, + "Role": { + "Fn::GetAtt": [ + "HelloServiceRole1E55EA16", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs10.x" + }, + "DependsOn": [ + "HelloServiceRole1E55EA16" + ] + }, + "StateMachineRoleB840431D": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "states.", + { + "Ref": "AWS::Region" + }, + ".amazonaws.com" + ] + ] + } + } + } + ], + "Version": "2012-10-17" + } + } + }, + "StateMachineRoleDefaultPolicyDF1E6607": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "execute-api:Invoke", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "MyRestApi2D1F47A9" + }, + "/prod/GET/*" + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "StateMachineRoleDefaultPolicyDF1E6607", + "Roles": [ + { + "Ref": "StateMachineRoleB840431D" + } + ] + } + }, + "StateMachine2E01A3A5": { + "Type": "AWS::StepFunctions::StateMachine", + "Properties": { + "RoleArn": { + "Fn::GetAtt": [ + "StateMachineRoleB840431D", + "Arn" + ] + }, + "DefinitionString": { + "Fn::Join": [ + "", + [ + "{\"StartAt\":\"Call APIGW\",\"States\":{\"Call APIGW\":{\"End\":true,\"Type\":\"Task\",\"OutputPath\":\"$.ResponseBody\",\"Resource\":\"arn:", + { + "Ref": "AWS::Partition" + }, + ":states:::apigateway:invoke\",\"Parameters\":{\"ApiEndpoint\":\"", + { + "Ref": "MyRestApi2D1F47A9" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "\",\"Method\":\"GET\",\"Stage\":\"prod\",\"AuthType\":\"IAM_ROLE\"}}},\"TimeoutSeconds\":30}" + ] + ] + } + }, + "DependsOn": [ + "StateMachineRoleDefaultPolicyDF1E6607", + "StateMachineRoleB840431D" + ] + } + }, + "Outputs": { + "MyRestApiEndpoint4C55E4CB": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "MyRestApi2D1F47A9" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "MyRestApiDeploymentStageprodC33B8E5F" + }, + "/" + ] + ] + } + }, + "stateMachineArn": { + "Value": { + "Ref": "StateMachine2E01A3A5" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/integ.call-rest-api.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/integ.call-rest-api.ts new file mode 100644 index 0000000000000..7cfe3c85ab12b --- /dev/null +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/test/apigateway/integ.call-rest-api.ts @@ -0,0 +1,43 @@ +import * as apigateway from '@aws-cdk/aws-apigateway'; +import * as lambda from '@aws-cdk/aws-lambda'; +import * as sfn from '@aws-cdk/aws-stepfunctions'; +import * as cdk from '@aws-cdk/core'; +import { AuthType, HttpMethod, CallApiGatewayRestApiEndpoint } from '../../lib'; + +/* + * Stack verification steps: + * * aws stepfunctions start-execution --state-machine-arn : should return execution arn + * + * * aws stepfunctions describe-execution --execution-arn --query 'status': should return status as SUCCEEDED + * * aws stepfunctions describe-execution --execution-arn --query 'output': should return the string \"hello, world!\" + */ + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'CallRestApiInteg'); +const restApi = new apigateway.RestApi(stack, 'MyRestApi'); + +const hello = new apigateway.LambdaIntegration(new lambda.Function(stack, 'Hello', { + runtime: lambda.Runtime.NODEJS_10_X, + handler: 'index.handler', + code: new lambda.InlineCode('exports.handler = async function(event, context) { return { statusCode: 200, body: "hello, world!" }; };'), +})); +restApi.root.addMethod('ANY', hello); + +const callEndpointJob = new CallApiGatewayRestApiEndpoint(stack, 'Call APIGW', { + api: restApi, + stageName: 'prod', + method: HttpMethod.GET, + authType: AuthType.IAM_ROLE, + outputPath: sfn.JsonPath.stringAt('$.ResponseBody'), +}); + +const chain = sfn.Chain.start(callEndpointJob); + +const sm = new sfn.StateMachine(stack, 'StateMachine', { + definition: chain, + timeout: cdk.Duration.seconds(30), +}); + +new cdk.CfnOutput(stack, 'stateMachineArn', { + value: sm.stateMachineArn, +}); From 66f7053a6c1f5cab540e975b30f5a2c6e35df58a Mon Sep 17 00:00:00 2001 From: Josh Kellendonk Date: Wed, 10 Mar 2021 18:31:45 -0700 Subject: [PATCH 07/14] feat(appmesh): add route retry policies (#13353) Adds route retry policies for http/http2 and gRPC routes. Closes #11642 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-appmesh/README.md | 44 +++ .../@aws-cdk/aws-appmesh/lib/route-spec.ts | 198 ++++++++++++ .../aws-appmesh/test/integ.mesh.expected.json | 129 +++++++- .../@aws-cdk/aws-appmesh/test/integ.mesh.ts | 26 ++ .../@aws-cdk/aws-appmesh/test/test.route.ts | 294 +++++++++++++++++- 5 files changed, 678 insertions(+), 13 deletions(-) diff --git a/packages/@aws-cdk/aws-appmesh/README.md b/packages/@aws-cdk/aws-appmesh/README.md index c400cbb0af05d..74aead1f02a02 100644 --- a/packages/@aws-cdk/aws-appmesh/README.md +++ b/packages/@aws-cdk/aws-appmesh/README.md @@ -320,6 +320,50 @@ router.addRoute('route-http', { }); ``` +Add an http2 route with retries: + +```ts +router.addRoute('route-http2-retry', { + routeSpec: appmesh.RouteSpec.http2({ + weightedTargets: [{ virtualNode: node }], + retryPolicy: { + // Retry if the connection failed + tcpRetryEvents: [appmesh.TcpRetryEvent.CONNECTION_ERROR], + // Retry if HTTP responds with a gateway error (502, 503, 504) + httpRetryEvents: [appmesh.HttpRetryEvent.GATEWAY_ERROR], + // Retry five times + retryAttempts: 5, + // Use a 1 second timeout per retry + retryTimeout: cdk.Duration.seconds(1), + }, + }), +}); +``` + +Add a gRPC route with retries: + +```ts +router.addRoute('route-grpc-retry', { + routeSpec: appmesh.RouteSpec.grpc({ + weightedTargets: [{ virtualNode: node }], + match: { serviceName: 'servicename' }, + retryPolicy: { + tcpRetryEvents: [appmesh.TcpRetryEvent.CONNECTION_ERROR], + httpRetryEvents: [appmesh.HttpRetryEvent.GATEWAY_ERROR], + // Retry if gRPC responds that the request was cancelled, a resource + // was exhausted, or if the service is unavailable + grpcRetryEvents: [ + appmesh.GrpcRetryEvent.CANCELLED, + appmesh.GrpcRetryEvent.RESOURCE_EXHAUSTED, + appmesh.GrpcRetryEvent.UNAVAILABLE, + ], + retryAttempts: 5, + retryTimeout: cdk.Duration.seconds(1), + }, + }), +}); +``` + The _RouteSpec_ class provides an easy interface for defining new protocol specific route specs. The `tcp()`, `http()` and `http2()` methods provide the spec necessary to define a protocol specific spec. diff --git a/packages/@aws-cdk/aws-appmesh/lib/route-spec.ts b/packages/@aws-cdk/aws-appmesh/lib/route-spec.ts index 74b16976b69ca..11f629c4aee91 100644 --- a/packages/@aws-cdk/aws-appmesh/lib/route-spec.ts +++ b/packages/@aws-cdk/aws-appmesh/lib/route-spec.ts @@ -1,3 +1,4 @@ +import * as cdk from '@aws-cdk/core'; import { CfnRoute } from './appmesh.generated'; import { Protocol, HttpTimeout, GrpcTimeout, TcpTimeout } from './shared-interfaces'; import { IVirtualNode } from './virtual-node'; @@ -68,6 +69,81 @@ export interface HttpRouteSpecOptions { * @default - None */ readonly timeout?: HttpTimeout; + + /** + * The retry policy + * + * @default - no retry policy + */ + readonly retryPolicy?: HttpRetryPolicy; +} + +/** + * HTTP retry policy + */ +export interface HttpRetryPolicy { + /** + * Specify HTTP events on which to retry. You must specify at least one value + * for at least one types of retry events. + * + * @default - no retries for http events + */ + readonly httpRetryEvents?: HttpRetryEvent[]; + + /** + * The maximum number of retry attempts + */ + readonly retryAttempts: number; + + /** + * The timeout for each retry attempt + */ + readonly retryTimeout: cdk.Duration; + + /** + * TCP events on which to retry. The event occurs before any processing of a + * request has started and is encountered when the upstream is temporarily or + * permanently unavailable. You must specify at least one value for at least + * one types of retry events. + * + * @default - no retries for tcp events + */ + readonly tcpRetryEvents?: TcpRetryEvent[]; +} + +/** + * HTTP events on which to retry. + */ +export enum HttpRetryEvent { + /** + * HTTP status codes 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, and 511 + */ + SERVER_ERROR = 'server-error', + + /** + * HTTP status codes 502, 503, and 504 + */ + GATEWAY_ERROR = 'gateway-error', + + /** + * HTTP status code 409 + */ + CLIENT_ERROR = 'client-error', + + /** + * Retry on refused stream + */ + STREAM_ERROR = 'stream-error', +} + +/** + * TCP events on which you may retry + */ +export enum TcpRetryEvent { + /** + * A connection error + */ + CONNECTION_ERROR = 'connection-error', } /** @@ -107,6 +183,64 @@ export interface GrpcRouteSpecOptions { * List of targets that traffic is routed to when a request matches the route */ readonly weightedTargets: WeightedTarget[]; + + /** + * The retry policy + * + * @default - no retry policy + */ + readonly retryPolicy?: GrpcRetryPolicy; +} + +/** gRPC retry policy */ +export interface GrpcRetryPolicy extends HttpRetryPolicy { + /** + * gRPC events on which to retry. You must specify at least one value + * for at least one types of retry events. + * + * @default - no retries for gRPC events + */ + readonly grpcRetryEvents?: GrpcRetryEvent[]; +} + +/** + * gRPC events + */ +export enum GrpcRetryEvent { + /** + * Request was cancelled + * + * @see https://grpc.github.io/grpc/core/md_doc_statuscodes.html + */ + CANCELLED = 'cancelled', + + /** + * The deadline was exceeded + * + * @see https://grpc.github.io/grpc/core/md_doc_statuscodes.html + */ + DEADLINE_EXCEEDED = 'deadline-exceeded', + + /** + * Internal error + * + * @see https://grpc.github.io/grpc/core/md_doc_statuscodes.html + */ + INTERNAL_ERROR = 'internal', + + /** + * A resource was exhausted + * + * @see https://grpc.github.io/grpc/core/md_doc_statuscodes.html + */ + RESOURCE_EXHAUSTED = 'resource-exhausted', + + /** + * The service is unavailable + * + * @see https://grpc.github.io/grpc/core/md_doc_statuscodes.html + */ + UNAVAILABLE = 'unavailable', } /** @@ -203,12 +337,32 @@ class HttpRouteSpec extends RouteSpec { */ public readonly weightedTargets: WeightedTarget[]; + /** + * The retry policy + */ + public readonly retryPolicy?: HttpRetryPolicy; + constructor(props: HttpRouteSpecOptions, protocol: Protocol) { super(); this.protocol = protocol; this.match = props.match; this.weightedTargets = props.weightedTargets; this.timeout = props.timeout; + + if (props.retryPolicy) { + const httpRetryEvents = props.retryPolicy.httpRetryEvents ?? []; + const tcpRetryEvents = props.retryPolicy.tcpRetryEvents ?? []; + + if (httpRetryEvents.length + tcpRetryEvents.length === 0) { + throw new Error('You must specify one value for at least one of `httpRetryEvents` or `tcpRetryEvents`'); + } + + this.retryPolicy = { + ...props.retryPolicy, + httpRetryEvents: httpRetryEvents.length > 0 ? httpRetryEvents : undefined, + tcpRetryEvents: tcpRetryEvents.length > 0 ? tcpRetryEvents : undefined, + }; + } } public bind(_scope: Construct): RouteSpecConfig { @@ -216,6 +370,7 @@ class HttpRouteSpec extends RouteSpec { if (prefixPath[0] != '/') { throw new Error(`Prefix Path must start with \'/\', got: ${prefixPath}`); } + const httpConfig: CfnRoute.HttpRouteProperty = { action: { weightedTargets: renderWeightedTargets(this.weightedTargets), @@ -224,6 +379,7 @@ class HttpRouteSpec extends RouteSpec { prefix: prefixPath, }, timeout: renderTimeout(this.timeout), + retryPolicy: this.retryPolicy ? renderHttpRetryPolicy(this.retryPolicy) : undefined, }; return { httpRouteSpec: this.protocol === Protocol.HTTP ? httpConfig : undefined, @@ -266,11 +422,33 @@ class GrpcRouteSpec extends RouteSpec { public readonly match: GrpcRouteMatch; public readonly timeout?: GrpcTimeout; + /** + * The retry policy. + */ + public readonly retryPolicy?: GrpcRetryPolicy; + constructor(props: GrpcRouteSpecOptions) { super(); this.weightedTargets = props.weightedTargets; this.match = props.match; this.timeout = props.timeout; + + if (props.retryPolicy) { + const grpcRetryEvents = props.retryPolicy.grpcRetryEvents ?? []; + const httpRetryEvents = props.retryPolicy.httpRetryEvents ?? []; + const tcpRetryEvents = props.retryPolicy.tcpRetryEvents ?? []; + + if (grpcRetryEvents.length + httpRetryEvents.length + tcpRetryEvents.length === 0) { + throw new Error('You must specify one value for at least one of `grpcRetryEvents`, `httpRetryEvents` or `tcpRetryEvents`'); + } + + this.retryPolicy = { + ...props.retryPolicy, + grpcRetryEvents: grpcRetryEvents.length > 0 ? grpcRetryEvents : undefined, + httpRetryEvents: httpRetryEvents.length > 0 ? httpRetryEvents : undefined, + tcpRetryEvents: tcpRetryEvents.length > 0 ? tcpRetryEvents : undefined, + }; + } } public bind(_scope: Construct): RouteSpecConfig { @@ -283,6 +461,7 @@ class GrpcRouteSpec extends RouteSpec { serviceName: this.match.serviceName, }, timeout: renderTimeout(this.timeout), + retryPolicy: this.retryPolicy ? renderGrpcRetryPolicy(this.retryPolicy) : undefined, }, }; } @@ -323,3 +502,22 @@ function renderTimeout(timeout?: HttpTimeout): CfnRoute.HttpTimeoutProperty | un } : undefined; } + +function renderHttpRetryPolicy(retryPolicy: HttpRetryPolicy): CfnRoute.HttpRetryPolicyProperty { + return { + maxRetries: retryPolicy.retryAttempts, + perRetryTimeout: { + unit: 'ms', + value: retryPolicy.retryTimeout.toMilliseconds(), + }, + httpRetryEvents: retryPolicy.httpRetryEvents, + tcpRetryEvents: retryPolicy.tcpRetryEvents, + }; +} + +function renderGrpcRetryPolicy(retryPolicy: GrpcRetryPolicy): CfnRoute.GrpcRetryPolicyProperty { + return { + ...renderHttpRetryPolicy(retryPolicy), + grpcRetryEvents: retryPolicy.grpcRetryEvents, + }; +} diff --git a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.expected.json b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.expected.json index 5f4a9ca206725..f951953924b44 100644 --- a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.expected.json +++ b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.expected.json @@ -497,7 +497,6 @@ "MeshName" ] }, - "RouteName": "route-1", "Spec": { "HttpRoute": { "Action": { @@ -533,7 +532,8 @@ "meshrouter81B8087E", "VirtualRouterName" ] - } + }, + "RouteName": "route-1" } }, "meshrouterroute2486D9DEF": { @@ -545,7 +545,6 @@ "MeshName" ] }, - "RouteName": "route-2", "Spec": { "HttpRoute": { "Action": { @@ -581,7 +580,8 @@ "meshrouter81B8087E", "VirtualRouterName" ] - } + }, + "RouteName": "route-2" } }, "meshrouterroute3BD0FA22F": { @@ -593,7 +593,6 @@ "MeshName" ] }, - "RouteName": "route-3", "Spec": { "TcpRoute": { "Action": { @@ -622,7 +621,113 @@ "meshrouter81B8087E", "VirtualRouterName" ] - } + }, + "RouteName": "route-3" + } + }, + "meshrouterroutehttp2retryCC41345F": { + "Type": "AWS::AppMesh::Route", + "Properties": { + "MeshName": { + "Fn::GetAtt": [ + "meshACDFE68E", + "MeshName" + ] + }, + "Spec": { + "Http2Route": { + "Action": { + "WeightedTargets": [ + { + "VirtualNode": { + "Fn::GetAtt": [ + "meshnode3D2A19CF2", + "VirtualNodeName" + ] + }, + "Weight": 1 + } + ] + }, + "Match": { + "Prefix": "/" + }, + "RetryPolicy": { + "HttpRetryEvents": [ + "client-error" + ], + "MaxRetries": 5, + "PerRetryTimeout": { + "Unit": "ms", + "Value": 1000 + }, + "TcpRetryEvents": [ + "connection-error" + ] + } + } + }, + "VirtualRouterName": { + "Fn::GetAtt": [ + "meshrouter81B8087E", + "VirtualRouterName" + ] + }, + "RouteName": "route-http2-retry" + } + }, + "meshrouterroutegrpcretry9BEB798A": { + "Type": "AWS::AppMesh::Route", + "Properties": { + "MeshName": { + "Fn::GetAtt": [ + "meshACDFE68E", + "MeshName" + ] + }, + "Spec": { + "GrpcRoute": { + "Action": { + "WeightedTargets": [ + { + "VirtualNode": { + "Fn::GetAtt": [ + "meshnode3D2A19CF2", + "VirtualNodeName" + ] + }, + "Weight": 1 + } + ] + }, + "Match": { + "ServiceName": "servicename" + }, + "RetryPolicy": { + "GrpcRetryEvents": [ + "deadline-exceeded" + ], + "HttpRetryEvents": [ + "client-error" + ], + "MaxRetries": 5, + "PerRetryTimeout": { + "Unit": "ms", + "Value": 1000 + }, + "TcpRetryEvents": [ + "connection-error" + ] + } + } + }, + "VirtualRouterName": { + "Fn::GetAtt": [ + "meshrouter81B8087E", + "VirtualRouterName" + ] + }, + "RouteName": "route-grpc-retry" } }, "meshnode726C787D": { @@ -832,7 +937,6 @@ "meshgateway1gateway1routehttpE8D6F433": { "Type": "AWS::AppMesh::GatewayRoute", "Properties": { - "GatewayRouteName": "meshstackmeshgateway1gateway1routehttpBA921D42", "MeshName": { "Fn::GetAtt": [ "meshACDFE68E", @@ -863,13 +967,13 @@ "meshgateway1B02387E8", "VirtualGatewayName" ] - } + }, + "GatewayRouteName": "meshstackmeshgateway1gateway1routehttpBA921D42" } }, "meshgateway1gateway1routehttp2FD69C306": { "Type": "AWS::AppMesh::GatewayRoute", "Properties": { - "GatewayRouteName": "meshstackmeshgateway1gateway1routehttp255781963", "MeshName": { "Fn::GetAtt": [ "meshACDFE68E", @@ -900,13 +1004,13 @@ "meshgateway1B02387E8", "VirtualGatewayName" ] - } + }, + "GatewayRouteName": "meshstackmeshgateway1gateway1routehttp255781963" } }, "meshgateway1gateway1routegrpc76486062": { "Type": "AWS::AppMesh::GatewayRoute", "Properties": { - "GatewayRouteName": "meshstackmeshgateway1gateway1routegrpcCD4D891D", "MeshName": { "Fn::GetAtt": [ "meshACDFE68E", @@ -942,7 +1046,8 @@ "meshgateway1B02387E8", "VirtualGatewayName" ] - } + }, + "GatewayRouteName": "meshstackmeshgateway1gateway1routegrpcCD4D891D" } }, "service6D174F83": { diff --git a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts index 90e54586f7f51..c1e909e38d75b 100644 --- a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts +++ b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts @@ -140,6 +140,32 @@ router.addRoute('route-3', { }), }); +router.addRoute('route-http2-retry', { + routeSpec: appmesh.RouteSpec.http2({ + weightedTargets: [{ virtualNode: node3 }], + retryPolicy: { + httpRetryEvents: [appmesh.HttpRetryEvent.CLIENT_ERROR], + tcpRetryEvents: [appmesh.TcpRetryEvent.CONNECTION_ERROR], + retryAttempts: 5, + retryTimeout: cdk.Duration.seconds(1), + }, + }), +}); + +router.addRoute('route-grpc-retry', { + routeSpec: appmesh.RouteSpec.grpc({ + weightedTargets: [{ virtualNode: node3 }], + match: { serviceName: 'servicename' }, + retryPolicy: { + grpcRetryEvents: [appmesh.GrpcRetryEvent.DEADLINE_EXCEEDED], + httpRetryEvents: [appmesh.HttpRetryEvent.CLIENT_ERROR], + tcpRetryEvents: [appmesh.TcpRetryEvent.CONNECTION_ERROR], + retryAttempts: 5, + retryTimeout: cdk.Duration.seconds(1), + }, + }), +}); + const gateway = mesh.addVirtualGateway('gateway1', { accessLog: appmesh.AccessLog.fromFilePath('/dev/stdout'), virtualGatewayName: 'gateway1', diff --git a/packages/@aws-cdk/aws-appmesh/test/test.route.ts b/packages/@aws-cdk/aws-appmesh/test/test.route.ts index cb5c92e6464cf..43c2d942a669b 100644 --- a/packages/@aws-cdk/aws-appmesh/test/test.route.ts +++ b/packages/@aws-cdk/aws-appmesh/test/test.route.ts @@ -1,4 +1,4 @@ -import { expect, haveResourceLike } from '@aws-cdk/assert'; +import { ABSENT, expect, haveResourceLike } from '@aws-cdk/assert'; import * as cdk from '@aws-cdk/core'; import { Test } from 'nodeunit'; @@ -282,6 +282,298 @@ export = { })); test.done(); }, + + 'should allow http retries'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + router.addRoute('test-http-route', { + routeSpec: appmesh.RouteSpec.http({ + weightedTargets: [{ virtualNode }], + retryPolicy: { + httpRetryEvents: [appmesh.HttpRetryEvent.CLIENT_ERROR], + tcpRetryEvents: [appmesh.TcpRetryEvent.CONNECTION_ERROR], + retryAttempts: 5, + retryTimeout: cdk.Duration.seconds(10), + }, + }), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + HttpRoute: { + RetryPolicy: { + HttpRetryEvents: ['client-error'], + TcpRetryEvents: ['connection-error'], + MaxRetries: 5, + PerRetryTimeout: { + Unit: 'ms', + Value: 10000, + }, + }, + }, + }, + })); + + test.done(); + }, + + 'http retry events are ABSENT when specified as an empty array'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + router.addRoute('test-http-route', { + routeSpec: appmesh.RouteSpec.http({ + weightedTargets: [{ virtualNode }], + retryPolicy: { + httpRetryEvents: [], + tcpRetryEvents: [appmesh.TcpRetryEvent.CONNECTION_ERROR], + retryAttempts: 5, + retryTimeout: cdk.Duration.seconds(10), + }, + }), + }); + + router.addRoute('test-http-route2', { + routeSpec: appmesh.RouteSpec.http({ + weightedTargets: [{ virtualNode }], + retryPolicy: { + httpRetryEvents: [appmesh.HttpRetryEvent.CLIENT_ERROR], + tcpRetryEvents: [], + retryAttempts: 5, + retryTimeout: cdk.Duration.seconds(10), + }, + }), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + HttpRoute: { + RetryPolicy: { + HttpRetryEvents: ABSENT, + TcpRetryEvents: ['connection-error'], + }, + }, + }, + })); + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + HttpRoute: { + RetryPolicy: { + HttpRetryEvents: ['client-error'], + TcpRetryEvents: ABSENT, + }, + }, + }, + })); + + test.done(); + }, + + 'errors when http retry policy has no events'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + test.throws(() => { + router.addRoute('test-http-route', { + routeSpec: appmesh.RouteSpec.http({ + weightedTargets: [{ virtualNode }], + retryPolicy: { + retryAttempts: 5, + retryTimeout: cdk.Duration.seconds(10), + }, + }), + }); + }, /specify one value for at least/i); + + test.done(); + }, + + 'should allow grpc retries'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + router.addRoute('test-grpc-route', { + routeSpec: appmesh.RouteSpec.grpc({ + weightedTargets: [{ virtualNode }], + match: { serviceName: 'servicename' }, + retryPolicy: { + grpcRetryEvents: [appmesh.GrpcRetryEvent.DEADLINE_EXCEEDED], + httpRetryEvents: [appmesh.HttpRetryEvent.CLIENT_ERROR], + tcpRetryEvents: [appmesh.TcpRetryEvent.CONNECTION_ERROR], + retryAttempts: 5, + retryTimeout: cdk.Duration.seconds(10), + }, + }), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + GrpcRoute: { + RetryPolicy: { + GrpcRetryEvents: ['deadline-exceeded'], + HttpRetryEvents: ['client-error'], + TcpRetryEvents: ['connection-error'], + MaxRetries: 5, + PerRetryTimeout: { + Unit: 'ms', + Value: 10000, + }, + }, + }, + }, + })); + + test.done(); + }, + + 'grpc retry events are ABSENT when specified as an empty array'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + router.addRoute('test-grpc-route', { + routeSpec: appmesh.RouteSpec.grpc({ + weightedTargets: [{ virtualNode }], + match: { serviceName: 'example' }, + retryPolicy: { + grpcRetryEvents: [], + httpRetryEvents: [], + tcpRetryEvents: [appmesh.TcpRetryEvent.CONNECTION_ERROR], + retryAttempts: 5, + retryTimeout: cdk.Duration.seconds(10), + }, + }), + }); + + router.addRoute('test-grpc-route2', { + routeSpec: appmesh.RouteSpec.grpc({ + weightedTargets: [{ virtualNode }], + match: { serviceName: 'example' }, + retryPolicy: { + grpcRetryEvents: [appmesh.GrpcRetryEvent.CANCELLED], + httpRetryEvents: [], + tcpRetryEvents: [], + retryAttempts: 5, + retryTimeout: cdk.Duration.seconds(10), + }, + }), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + GrpcRoute: { + RetryPolicy: { + GrpcRetryEvents: ABSENT, + HttpRetryEvents: ABSENT, + TcpRetryEvents: ['connection-error'], + }, + }, + }, + })); + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + GrpcRoute: { + RetryPolicy: { + GrpcRetryEvents: ['cancelled'], + HttpRetryEvents: ABSENT, + TcpRetryEvents: ABSENT, + }, + }, + }, + })); + + test.done(); + }, + + 'errors when grpc retry policy has no events'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + test.throws(() => { + router.addRoute('test-grpc-route', { + routeSpec: appmesh.RouteSpec.grpc({ + weightedTargets: [{ virtualNode }], + match: { serviceName: 'servicename' }, + retryPolicy: { + retryAttempts: 5, + retryTimeout: cdk.Duration.seconds(10), + }, + }), + }); + }, /specify one value for at least/i); + + test.done(); + }, }, 'Can import Routes using an ARN'(test: Test) { From 4c63f09f1e9644877eaffbe78eede3854bec08ab Mon Sep 17 00:00:00 2001 From: Janario Oliveira Date: Thu, 11 Mar 2021 07:39:45 +0100 Subject: [PATCH 08/14] feat(amplify-domain): Added config for auto subdomain creation (#13342) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-amplify/README.md | 5 +- packages/@aws-cdk/aws-amplify/lib/app.ts | 1 + packages/@aws-cdk/aws-amplify/lib/domain.ts | 24 ++++ .../@aws-cdk/aws-amplify/test/domain.test.ts | 111 ++++++++++++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) diff --git a/packages/@aws-cdk/aws-amplify/README.md b/packages/@aws-cdk/aws-amplify/README.md index 7c15079d914d6..b8e3bd91c2306 100644 --- a/packages/@aws-cdk/aws-amplify/README.md +++ b/packages/@aws-cdk/aws-amplify/README.md @@ -122,7 +122,10 @@ mySinglePageApp.addCustomRule(amplify.CustomRule.SINGLE_PAGE_APPLICATION_REDIREC Add a domain and map sub domains to branches: ```ts -const domain = amplifyApp.addDomain('example.com'); +const domain = amplifyApp.addDomain('example.com', { + enableAutoSubdomain: true, // in case subdomains should be auto registered for branches + autoSubdomainCreationPatterns: ['*', 'pr*'], // regex for branches that should auto register subdomains +}); domain.mapRoot(master); // map master branch to domain root domain.mapSubDomain(master, 'www'); domain.mapSubDomain(dev); // sub domain prefix defaults to branch name diff --git a/packages/@aws-cdk/aws-amplify/lib/app.ts b/packages/@aws-cdk/aws-amplify/lib/app.ts index bf1d5bc3d5e89..43f8e308cb8f9 100644 --- a/packages/@aws-cdk/aws-amplify/lib/app.ts +++ b/packages/@aws-cdk/aws-amplify/lib/app.ts @@ -289,6 +289,7 @@ export class App extends Resource implements IApp, iam.IGrantable { return new Domain(this, id, { ...options, app: this, + autoSubDomainIamRole: this.grantPrincipal as iam.IRole, }); } } diff --git a/packages/@aws-cdk/aws-amplify/lib/domain.ts b/packages/@aws-cdk/aws-amplify/lib/domain.ts index bf6ba7afe8017..f8683f44b123d 100644 --- a/packages/@aws-cdk/aws-amplify/lib/domain.ts +++ b/packages/@aws-cdk/aws-amplify/lib/domain.ts @@ -1,3 +1,4 @@ +import * as iam from '@aws-cdk/aws-iam'; import { Lazy, Resource, IResolvable } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { CfnDomain } from './amplify.generated'; @@ -21,6 +22,20 @@ export interface DomainOptions { * @default - use `addSubDomain()` to add subdomains */ readonly subDomains?: SubDomain[]; + + /** + * Automatically create subdomains for connected branches + * + * @default false + */ + readonly enableAutoSubdomain?: boolean; + + /** + * Branches which should automatically create subdomains + * + * @default - all repository branches ['*', 'pr*'] + */ + readonly autoSubdomainCreationPatterns?: string[]; } /** @@ -31,6 +46,12 @@ export interface DomainProps extends DomainOptions { * The application to which the domain must be connected */ readonly app: IApp; + + /** + * The IAM role with access to Route53 when using enableAutoSubdomain + * @default the IAM role from App.grantPrincipal + */ + readonly autoSubDomainIamRole?: iam.IRole; } /** @@ -106,6 +127,9 @@ export class Domain extends Resource { appId: props.app.appId, domainName, subDomainSettings: Lazy.any({ produce: () => this.renderSubDomainSettings() }, { omitEmptyArray: true }), + enableAutoSubDomain: !!props.enableAutoSubdomain, + autoSubDomainCreationPatterns: props.autoSubdomainCreationPatterns || ['*', 'pr*'], + autoSubDomainIamRole: props.autoSubDomainIamRole?.roleArn, }); this.arn = domain.attrArn; diff --git a/packages/@aws-cdk/aws-amplify/test/domain.test.ts b/packages/@aws-cdk/aws-amplify/test/domain.test.ts index ca7c211d14094..7b0f28f75837d 100644 --- a/packages/@aws-cdk/aws-amplify/test/domain.test.ts +++ b/packages/@aws-cdk/aws-amplify/test/domain.test.ts @@ -1,3 +1,4 @@ +import * as iam from '@aws-cdk/aws-iam'; import '@aws-cdk/assert/jest'; import { App, SecretValue, Stack } from '@aws-cdk/core'; import * as amplify from '../lib'; @@ -120,3 +121,113 @@ test('throws at synthesis without subdomains', () => { // THEN expect(() => app.synth()).toThrow(/The domain doesn't contain any subdomains/); }); + +test('auto subdomain all branches', () => { + // GIVEN + const stack = new Stack(); + const app = new amplify.App(stack, 'App', { + sourceCodeProvider: new amplify.GitHubSourceCodeProvider({ + owner: 'aws', + repository: 'aws-cdk', + oauthToken: SecretValue.plainText('secret'), + }), + }); + const prodBranch = app.addBranch('master'); + + // WHEN + const domain = app.addDomain('amazon.com', { + enableAutoSubdomain: true, + }); + domain.mapRoot(prodBranch); + + // THEN + expect(stack).toHaveResource('AWS::Amplify::Domain', { + EnableAutoSubDomain: true, + AutoSubDomainCreationPatterns: [ + '*', + 'pr*', + ], + AutoSubDomainIAMRole: { + 'Fn::GetAtt': [ + 'AppRole1AF9B530', + 'Arn', + ], + }, + }); +}); + +test('auto subdomain some branches', () => { + // GIVEN + const stack = new Stack(); + const app = new amplify.App(stack, 'App', { + sourceCodeProvider: new amplify.GitHubSourceCodeProvider({ + owner: 'aws', + repository: 'aws-cdk', + oauthToken: SecretValue.plainText('secret'), + }), + }); + const prodBranch = app.addBranch('master'); + + // WHEN + const domain = app.addDomain('amazon.com', { + enableAutoSubdomain: true, + autoSubdomainCreationPatterns: ['features/**'], + }); + domain.mapRoot(prodBranch); + + // THEN + expect(stack).toHaveResource('AWS::Amplify::Domain', { + EnableAutoSubDomain: true, + AutoSubDomainCreationPatterns: ['features/**'], + AutoSubDomainIAMRole: { + 'Fn::GetAtt': [ + 'AppRole1AF9B530', + 'Arn', + ], + }, + }); +}); + +test('auto subdomain with IAM role', () => { + // GIVEN + const stack = new Stack(); + const app = new amplify.App(stack, 'App', { + sourceCodeProvider: new amplify.GitHubSourceCodeProvider({ + owner: 'aws', + repository: 'aws-cdk', + oauthToken: SecretValue.plainText('secret'), + }), + role: iam.Role.fromRoleArn( + stack, + 'AmplifyRole', + `arn:aws:iam::${Stack.of(stack).account}:role/AmplifyRole`, + { mutable: false }, + ), + }); + const prodBranch = app.addBranch('master'); + + // WHEN + const domain = app.addDomain('amazon.com', { + enableAutoSubdomain: true, + autoSubdomainCreationPatterns: ['features/**'], + }); + domain.mapRoot(prodBranch); + + // THEN + expect(stack).toHaveResource('AWS::Amplify::Domain', { + EnableAutoSubDomain: true, + AutoSubDomainCreationPatterns: ['features/**'], + AutoSubDomainIAMRole: { + 'Fn::Join': [ + '', + [ + 'arn:aws:iam::', + { + Ref: 'AWS::AccountId', + }, + ':role/AmplifyRole', + ], + ], + }, + }); +}); \ No newline at end of file From e9cd1e84df3a99bca4ac98890c729f8dec899fd7 Mon Sep 17 00:00:00 2001 From: Romain Marcadier Date: Thu, 11 Mar 2021 11:20:26 +0100 Subject: [PATCH 09/14] chore(test): make metadata resource test immune to encoding (#13538) The prefix-encoded trie could occasionally encode the tested resource name in a way that prevents the test to match. Using a "fake" version number ensures a unique prefix is always present, and hence the tested entry will never be encoded in unexpected ways. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/core/test/metadata-resource.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/@aws-cdk/core/test/metadata-resource.test.ts b/packages/@aws-cdk/core/test/metadata-resource.test.ts index 2275bcf7dee9d..00869746b1e25 100644 --- a/packages/@aws-cdk/core/test/metadata-resource.test.ts +++ b/packages/@aws-cdk/core/test/metadata-resource.test.ts @@ -63,7 +63,7 @@ describe('MetadataResource', () => { test('includes constructs added to the stack', () => { new TestConstruct(stack, 'Test'); - expect(stackAnalytics()).toContain('1.2.3!@amzn/core.TestConstruct'); + expect(stackAnalytics()).toContain('FakeVersion.2.3!@amzn/core.TestConstruct'); }); test('only includes constructs in the allow list', () => { @@ -141,11 +141,10 @@ const JSII_RUNTIME_SYMBOL = Symbol.for('jsii.rtti'); class TestConstruct extends Construct { // @ts-ignore - private static readonly [JSII_RUNTIME_SYMBOL] = { fqn: '@amzn/core.TestConstruct', version: '1.2.3' } + private static readonly [JSII_RUNTIME_SYMBOL] = { fqn: '@amzn/core.TestConstruct', version: 'FakeVersion.2.3' } } class TestThirdPartyConstruct extends Construct { // @ts-ignore private static readonly [JSII_RUNTIME_SYMBOL] = { fqn: 'mycoolthing.TestConstruct', version: '1.2.3' } } - From b5d4b923ea55a034b90eb7a30b0e647daf7524ec Mon Sep 17 00:00:00 2001 From: AWS CDK Team Date: Thu, 11 Mar 2021 12:33:11 +0000 Subject: [PATCH 10/14] chore(release): 1.93.0 --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ version.v1.json | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fad4611dfad6c..ca6ebeb1b8572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [1.93.0](https://github.com/aws/aws-cdk/compare/v1.92.0...v1.93.0) (2021-03-11) + + +### Features + +* **amplify-domain:** Added config for auto subdomain creation ([#13342](https://github.com/aws/aws-cdk/issues/13342)) ([4c63f09](https://github.com/aws/aws-cdk/commit/4c63f09f1e9644877eaffbe78eede3854bec08ab)) +* **appmesh:** add route retry policies ([#13353](https://github.com/aws/aws-cdk/issues/13353)) ([66f7053](https://github.com/aws/aws-cdk/commit/66f7053a6c1f5cab540e975b30f5a2c6e35df58a)), closes [#11642](https://github.com/aws/aws-cdk/issues/11642) +* **cfnspec:** cloudformation spec v30.1.0 ([#13519](https://github.com/aws/aws-cdk/issues/13519)) ([7711981](https://github.com/aws/aws-cdk/commit/7711981ea30bfdffd21dd840d676be4a2b45c9ba)) +* **codebuild:** allow setting queued timeout ([#13467](https://github.com/aws/aws-cdk/issues/13467)) ([e09250b](https://github.com/aws/aws-cdk/commit/e09250bc92c62cb8ee0a8706ce90d0e82faf2d84)), closes [#11364](https://github.com/aws/aws-cdk/issues/11364) +* **dynamodb:** custom timeout for replication operation ([#13354](https://github.com/aws/aws-cdk/issues/13354)) ([6a5a4f2](https://github.com/aws/aws-cdk/commit/6a5a4f2d9bb6b09ad0d10066200fe53bb45f0737)), closes [#10249](https://github.com/aws/aws-cdk/issues/10249) +* **ec2:** ESP and AH IPsec protocols for Security Groups ([#13471](https://github.com/aws/aws-cdk/issues/13471)) ([f5a6647](https://github.com/aws/aws-cdk/commit/f5a6647bbe1885ba86029d10550a3ffaf80b6561)), closes [#13403](https://github.com/aws/aws-cdk/issues/13403) +* **ec2:** multipart user data ([#11843](https://github.com/aws/aws-cdk/issues/11843)) ([ed94c5e](https://github.com/aws/aws-cdk/commit/ed94c5ef1b9dd3042128b0e0c5bb14b3d9c7d497)), closes [#8315](https://github.com/aws/aws-cdk/issues/8315) +* **ecr:** add imageTagMutability prop ([#10557](https://github.com/aws/aws-cdk/issues/10557)) ([c4dc3bc](https://github.com/aws/aws-cdk/commit/c4dc3bce02790903593d80b070fca81fe7b7f08c)), closes [#4640](https://github.com/aws/aws-cdk/issues/4640) +* **ecs:** ability to access tag parameter value of TagParameterContainerImage ([#13340](https://github.com/aws/aws-cdk/issues/13340)) ([e567a41](https://github.com/aws/aws-cdk/commit/e567a410d47366855ee3e6011aa096ba987b8099)), closes [#13202](https://github.com/aws/aws-cdk/issues/13202) +* **ecs:** allow users to provide a CloudMap service to associate with an ECS service ([#13192](https://github.com/aws/aws-cdk/issues/13192)) ([a7d314c](https://github.com/aws/aws-cdk/commit/a7d314c73b9473208d94bac29ad9bd8018e00204)), closes [#10057](https://github.com/aws/aws-cdk/issues/10057) +* **events:** `EventBus.grantPutEventsTo` method for granular grants ([#13429](https://github.com/aws/aws-cdk/issues/13429)) ([122a232](https://github.com/aws/aws-cdk/commit/122a232343699304d8f206d3024fcddfb2a94bc8)), closes [#11228](https://github.com/aws/aws-cdk/issues/11228) +* **events:** dead-letter queue support for CodeBuild ([#13448](https://github.com/aws/aws-cdk/issues/13448)) ([abfc0ea](https://github.com/aws/aws-cdk/commit/abfc0ea63c10d8033a529b7497cf093e318fdf12)), closes [#13447](https://github.com/aws/aws-cdk/issues/13447) +* **events:** dead-letter queue support for StepFunctions ([#13450](https://github.com/aws/aws-cdk/issues/13450)) ([0ebcb41](https://github.com/aws/aws-cdk/commit/0ebcb4160ee16f0f7ff1072a40c8951f9a983048)), closes [#13449](https://github.com/aws/aws-cdk/issues/13449) +* **events,applicationautoscaling:** schedule can be a token ([#13064](https://github.com/aws/aws-cdk/issues/13064)) ([b1449a1](https://github.com/aws/aws-cdk/commit/b1449a178b0f9a8a951c2546428f8d75c6431f0f)) +* **iam:** SAML identity provider ([#13393](https://github.com/aws/aws-cdk/issues/13393)) ([faa0c06](https://github.com/aws/aws-cdk/commit/faa0c060dad9a5045495707e28fc85f223d4db5d)), closes [#5320](https://github.com/aws/aws-cdk/issues/5320) +* **neptune:** Support IAM authentication ([#13462](https://github.com/aws/aws-cdk/issues/13462)) ([6c5b1f4](https://github.com/aws/aws-cdk/commit/6c5b1f42fb73a132d47945b529bab73557f2b9d8)), closes [#13461](https://github.com/aws/aws-cdk/issues/13461) +* **region-info:** added AppMesh ECR account for af-south-1 region ([#12814](https://github.com/aws/aws-cdk/issues/12814)) ([b3fba43](https://github.com/aws/aws-cdk/commit/b3fba43a047df61e713e8d2271d6deee7e07b716)) +* **stepfunctions-tasks:** Support calling ApiGateway REST and HTTP APIs ([#13033](https://github.com/aws/aws-cdk/issues/13033)) ([cc608d0](https://github.com/aws/aws-cdk/commit/cc608d055ffefb798ad6378ab07f36cb241897da)), closes [#11565](https://github.com/aws/aws-cdk/issues/11565) [#11566](https://github.com/aws/aws-cdk/issues/11566) [#11565](https://github.com/aws/aws-cdk/issues/11565) + + +### Bug Fixes + +* **cfn-include:** allow boolean values for string-typed properties ([#13508](https://github.com/aws/aws-cdk/issues/13508)) ([e5dab7c](https://github.com/aws/aws-cdk/commit/e5dab7cbc67c234d191c38a8b8b84b634070b15b)) +* **ec2:** fix typo's in WindowsImage constants ([#13446](https://github.com/aws/aws-cdk/issues/13446)) ([781aa97](https://github.com/aws/aws-cdk/commit/781aa97d53fdb7511c34ddde884fdcd84c3f68a6)) +* **elasticloadbalancingv2:** upgrade to v1.92.0 drops certificates on ALB if more than 2 certificates exist ([#13490](https://github.com/aws/aws-cdk/issues/13490)) ([01b94f8](https://github.com/aws/aws-cdk/commit/01b94f8aa6c88b5e676c784aec4c879acddc042f)), closes [#13332](https://github.com/aws/aws-cdk/issues/13332) [#13437](https://github.com/aws/aws-cdk/issues/13437) +* **events:** imported EventBus does not correctly register source account ([#13481](https://github.com/aws/aws-cdk/issues/13481)) ([57e5404](https://github.com/aws/aws-cdk/commit/57e540432c1446f2233a9b0c0f4caba4e9e155d9)), closes [#13469](https://github.com/aws/aws-cdk/issues/13469) +* **iam:** oidc-provider can't pull from hosts requiring SNI ([#13397](https://github.com/aws/aws-cdk/issues/13397)) ([90dbfb5](https://github.com/aws/aws-cdk/commit/90dbfb5eec19559717ac6b30f25451461027e731)) +* **iam:** policy statement tries to validate tokens ([#13493](https://github.com/aws/aws-cdk/issues/13493)) ([8d592ea](https://github.com/aws/aws-cdk/commit/8d592ea89c0eda19329d5a31517522ec02ceb874)), closes [#13479](https://github.com/aws/aws-cdk/issues/13479) +* **init:** Python init template's stack ID doesn't match other languages ([#13480](https://github.com/aws/aws-cdk/issues/13480)) ([3f1c02d](https://github.com/aws/aws-cdk/commit/3f1c02dac7a50ce7caebce1e7f8953f6e4937e6b)) +* **stepfunctions:** no validation on state machine name ([#13387](https://github.com/aws/aws-cdk/issues/13387)) ([6c3d407](https://github.com/aws/aws-cdk/commit/6c3d4071746179dde30f615602592c2523daa56e)), closes [#13289](https://github.com/aws/aws-cdk/issues/13289) + ## [1.92.0](https://github.com/aws/aws-cdk/compare/v1.91.0...v1.92.0) (2021-03-06) * **ecs-patterns**: the `desiredCount` property stored on the above constructs will be optional, allowing them to be undefined. This is enabled through the `@aws-cdk/aws-ecs-patterns:removeDefaultDesiredCount` feature flag. We would recommend all CDK users to set the `@aws-cdk/aws-ecs-patterns:removeDefaultDesiredCount` flag to `true` for all of their existing applications. diff --git a/version.v1.json b/version.v1.json index c2a1515792517..097cc55f8cc18 100644 --- a/version.v1.json +++ b/version.v1.json @@ -1,3 +1,3 @@ { - "version": "1.92.0" + "version": "1.93.0" } From 77449f61e7075fef1240fc52becb8ea60b9ea9ad Mon Sep 17 00:00:00 2001 From: Jonathan Goldwasser Date: Thu, 11 Mar 2021 15:26:41 +0100 Subject: [PATCH 11/14] fix(lambda): fromDockerBuild output is located under /asset (#13539) Ensure `imagePath` ends with `/.` so that the content at that location is copied. See https://docs.docker.com/engine/reference/commandline/cp/ Closes #13439 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-lambda/lib/code.ts | 12 +++- .../@aws-cdk/aws-lambda/test/code.test.ts | 56 ++++++++++++++++++- .../test/docker-build-lambda/Dockerfile | 2 +- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/@aws-cdk/aws-lambda/lib/code.ts b/packages/@aws-cdk/aws-lambda/lib/code.ts index b4f41b2804257..fec1e1821270e 100644 --- a/packages/@aws-cdk/aws-lambda/lib/code.ts +++ b/packages/@aws-cdk/aws-lambda/lib/code.ts @@ -67,9 +67,19 @@ export abstract class Code { * @param options Docker build options */ public static fromDockerBuild(path: string, options: DockerBuildAssetOptions = {}): AssetCode { + let imagePath = options.imagePath ?? '/asset/.'; + + // ensure imagePath ends with /. to copy the **content** at this path + if (imagePath.endsWith('/')) { + imagePath = `${imagePath}.`; + } else if (!imagePath.endsWith('/.')) { + imagePath = `${imagePath}/.`; + } + const assetPath = cdk.DockerImage .fromBuild(path, options) - .cp(options.imagePath ?? '/asset', options.outputPath); + .cp(imagePath, options.outputPath); + return new AssetCode(assetPath); } diff --git a/packages/@aws-cdk/aws-lambda/test/code.test.ts b/packages/@aws-cdk/aws-lambda/test/code.test.ts index 91de07a17c5a6..c976f0a1dabf2 100644 --- a/packages/@aws-cdk/aws-lambda/test/code.test.ts +++ b/packages/@aws-cdk/aws-lambda/test/code.test.ts @@ -331,6 +331,23 @@ describe('code', () => { }); describe('lambda.Code.fromDockerBuild', () => { + let fromBuildMock: jest.SpyInstance; + let cpMock: jest.Mock; + + beforeEach(() => { + cpMock = jest.fn().mockReturnValue(path.join(__dirname, 'docker-build-lambda')); + fromBuildMock = jest.spyOn(cdk.DockerImage, 'fromBuild').mockImplementation(() => ({ + cp: cpMock, + image: 'tag', + run: jest.fn(), + toJSON: jest.fn(), + })); + }); + + afterEach(() => { + fromBuildMock.mockRestore(); + }); + test('can use the result of a Docker build as an asset', () => { // given const stack = new cdk.Stack(); @@ -346,10 +363,47 @@ describe('code', () => { // then expect(stack).toHaveResource('AWS::Lambda::Function', { Metadata: { - [cxapi.ASSET_RESOURCE_METADATA_PATH_KEY]: 'asset.38cd320fa97b348accac88e48d9cede4923f7cab270ce794c95a665be83681a8', + [cxapi.ASSET_RESOURCE_METADATA_PATH_KEY]: 'asset.fbafdbb9ae8d1bae0def415b791a93c486d18ebc63270c748abecc3ac0ab9533', [cxapi.ASSET_RESOURCE_METADATA_PROPERTY_KEY]: 'Code', }, }, ResourcePart.CompleteDefinition); + + expect(fromBuildMock).toHaveBeenCalledWith(path.join(__dirname, 'docker-build-lambda'), {}); + expect(cpMock).toHaveBeenCalledWith('/asset/.', undefined); + }); + + test('fromDockerBuild appends /. to an image path not ending with a /', () => { + // given + const stack = new cdk.Stack(); + + // when + new lambda.Function(stack, 'Fn', { + code: lambda.Code.fromDockerBuild(path.join(__dirname, 'docker-build-lambda'), { + imagePath: '/my/image/path', + }), + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_12_X, + }); + + // then + expect(cpMock).toHaveBeenCalledWith('/my/image/path/.', undefined); + }); + + test('fromDockerBuild appends . to an image path ending with a /', () => { + // given + const stack = new cdk.Stack(); + + // when + new lambda.Function(stack, 'Fn', { + code: lambda.Code.fromDockerBuild(path.join(__dirname, 'docker-build-lambda'), { + imagePath: '/my/image/path/', + }), + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_12_X, + }); + + // then + expect(cpMock).toHaveBeenCalledWith('/my/image/path/.', undefined); }); }); }); diff --git a/packages/@aws-cdk/aws-lambda/test/docker-build-lambda/Dockerfile b/packages/@aws-cdk/aws-lambda/test/docker-build-lambda/Dockerfile index 4643fde141850..f22181359dc11 100644 --- a/packages/@aws-cdk/aws-lambda/test/docker-build-lambda/Dockerfile +++ b/packages/@aws-cdk/aws-lambda/test/docker-build-lambda/Dockerfile @@ -1,3 +1,3 @@ FROM public.ecr.aws/amazonlinux/amazonlinux:latest -COPY index.js /asset +COPY index.js /asset/ From b71efd9d12843ab4b495d53e565cec97d60748f3 Mon Sep 17 00:00:00 2001 From: Josh Kellendonk Date: Thu, 11 Mar 2021 16:55:21 -0700 Subject: [PATCH 12/14] feat(appmesh): add missing route match features (#13350) Adds route priority, header matching and matching by scheme and method. Closes #11645 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-appmesh/README.md | 24 ++ .../@aws-cdk/aws-appmesh/lib/route-spec.ts | 312 +++++++++++++++++- packages/@aws-cdk/aws-appmesh/lib/route.ts | 1 + .../aws-appmesh/test/integ.mesh.expected.json | 157 +++++++++ .../@aws-cdk/aws-appmesh/test/integ.mesh.ts | 30 ++ .../@aws-cdk/aws-appmesh/test/test.route.ts | 272 +++++++++++++++ 6 files changed, 778 insertions(+), 18 deletions(-) diff --git a/packages/@aws-cdk/aws-appmesh/README.md b/packages/@aws-cdk/aws-appmesh/README.md index 74aead1f02a02..63203d6b365d1 100644 --- a/packages/@aws-cdk/aws-appmesh/README.md +++ b/packages/@aws-cdk/aws-appmesh/README.md @@ -298,6 +298,30 @@ router.addRoute('route-http', { }); ``` +Add an HTTP2 route that matches based on method, scheme and header: + +```ts +router.addRoute('route-http2', { + routeSpec: appmesh.RouteSpec.http2({ + weightedTargets: [ + { + virtualNode: node, + }, + ], + match: { + prefixPath: '/', + method: appmesh.HttpRouteMatchMethod.POST, + protocol: appmesh.HttpRouteProtocol.HTTPS, + headers: [ + // All specified headers must match for the route to match. + appmesh.HttpHeaderMatch.valueIs('Content-Type', 'application/json'), + appmesh.HttpHeaderMatch.valueIsNot('Content-Type', 'application/json'), + ] + }, + }), +}); +``` + Add a single route with multiple targets and split traffic 50/50 ```ts diff --git a/packages/@aws-cdk/aws-appmesh/lib/route-spec.ts b/packages/@aws-cdk/aws-appmesh/lib/route-spec.ts index 11f629c4aee91..add785c02c286 100644 --- a/packages/@aws-cdk/aws-appmesh/lib/route-spec.ts +++ b/packages/@aws-cdk/aws-appmesh/lib/route-spec.ts @@ -35,6 +35,255 @@ export interface HttpRouteMatch { * and you want the route to match requests to my-service.local/metrics, your prefix should be /metrics. */ readonly prefixPath: string; + + /** + * Specifies the client request headers to match on. All specified headers + * must match for the route to match. + * + * @default - do not match on headers + */ + readonly headers?: HttpHeaderMatch[]; + + /** + * The HTTP client request method to match on. + * + * @default - do not match on request method + */ + readonly method?: HttpRouteMatchMethod; + + /** + * The client request protocol to match on. Applicable only for HTTP2 routes. + * + * @default - do not match on HTTP2 request protocol + */ + readonly protocol?: HttpRouteProtocol; +} + +/** + * Supported values for matching routes based on the HTTP request method + */ +export enum HttpRouteMatchMethod { + /** + * GET request + */ + GET = 'GET', + + /** + * HEAD request + */ + HEAD = 'HEAD', + + /** + * POST request + */ + POST = 'POST', + + /** + * PUT request + */ + PUT = 'PUT', + + /** + * DELETE request + */ + DELETE = 'DELETE', + + /** + * CONNECT request + */ + CONNECT = 'CONNECT', + + /** + * OPTIONS request + */ + OPTIONS = 'OPTIONS', + + /** + * TRACE request + */ + TRACE = 'TRACE', + + /** + * PATCH request + */ + PATCH = 'PATCH', +} + +/** + * Supported :scheme options for HTTP2 + */ +export enum HttpRouteProtocol { + /** + * Match HTTP requests + */ + HTTP = 'http', + + /** + * Match HTTPS requests + */ + HTTPS = 'https', +} + +/** + * Configuration for `HeaderMatch` + */ +export interface HttpHeaderMatchConfig { + /** + * The HTTP route header. + */ + readonly httpRouteHeader: CfnRoute.HttpRouteHeaderProperty; +} + +/** + * Used to generate header matching methods. + */ +export abstract class HttpHeaderMatch { + /** + * The value of the header with the given name in the request must match the + * specified value exactly. + * + * @param headerName the name of the HTTP header to match against + * @param headerValue The exact value to test against + */ + static valueIs(headerName: string, headerValue: string): HttpHeaderMatch { + return new HeaderMatchImpl(headerName, false, { exact: headerValue }); + } + + /** + * The value of the header with the given name in the request must not match + * the specified value exactly. + * + * @param headerName the name of the HTTP header to match against + * @param headerValue The exact value to test against + */ + static valueIsNot(headerName: string, headerValue: string): HttpHeaderMatch { + return new HeaderMatchImpl(headerName, true, { exact: headerValue }); + } + + /** + * The value of the header with the given name in the request must start with + * the specified characters. + * + * @param headerName the name of the HTTP header to match against + * @param prefix The prefix to test against + */ + static valueStartsWith(headerName: string, prefix: string): HttpHeaderMatch { + return new HeaderMatchImpl(headerName, false, { prefix }); + } + + /** + * The value of the header with the given name in the request must not start + * with the specified characters. + * + * @param headerName the name of the HTTP header to match against + * @param prefix The prefix to test against + */ + static valueDoesNotStartWith(headerName: string, prefix: string): HttpHeaderMatch { + return new HeaderMatchImpl(headerName, true, { prefix }); + } + + /** + * The value of the header with the given name in the request must end with + * the specified characters. + * + * @param headerName the name of the HTTP header to match against + * @param suffix The suffix to test against + */ + static valueEndsWith(headerName: string, suffix: string): HttpHeaderMatch { + return new HeaderMatchImpl(headerName, false, { suffix }); + } + + /** + * The value of the header with the given name in the request must not end + * with the specified characters. + * + * @param headerName the name of the HTTP header to match against + * @param suffix The suffix to test against + */ + static valueDoesNotEndWith(headerName: string, suffix: string): HttpHeaderMatch { + return new HeaderMatchImpl(headerName, true, { suffix }); + } + + /** + * The value of the header with the given name in the request must include + * the specified characters. + * + * @param headerName the name of the HTTP header to match against + * @param regex The regex to test against + */ + static valueMatchesRegex(headerName: string, regex: string): HttpHeaderMatch { + return new HeaderMatchImpl(headerName, false, { regex }); + } + + /** + * The value of the header with the given name in the request must not + * include the specified characters. + * + * @param headerName the name of the HTTP header to match against + * @param regex The regex to test against + */ + static valueDoesNotMatchRegex(headerName: string, regex: string): HttpHeaderMatch { + return new HeaderMatchImpl(headerName, true, { regex }); + } + + /** + * The value of the header with the given name in the request must be in a + * range of values. + * + * @param headerName the name of the HTTP header to match against + * @param start Match on values starting at and including this value + * @param end Match on values up to but not including this value + */ + static valuesIsInRange(headerName: string, start: number, end: number): HttpHeaderMatch { + return new HeaderMatchImpl(headerName, false, { + range: { + start, + end, + }, + }); + } + + /** + * The value of the header with the given name in the request must not be in + * a range of values. + * + * @param headerName the name of the HTTP header to match against + * @param start Match on values starting at and including this value + * @param end Match on values up to but not including this value + */ + static valuesIsNotInRange(headerName: string, start: number, end: number): HttpHeaderMatch { + return new HeaderMatchImpl(headerName, true, { + range: { + start, + end, + }, + }); + } + + /** + * Returns the header match configuration. + */ + abstract bind(scope: Construct): HttpHeaderMatchConfig; +} + +class HeaderMatchImpl extends HttpHeaderMatch { + constructor( + private readonly headerName: string, + private readonly invert: boolean, + private readonly matchProperty: CfnRoute.HeaderMatchMethodProperty, + ) { + super(); + } + + bind(_scope: Construct): HttpHeaderMatchConfig { + return { + httpRouteHeader: { + name: this.headerName, + invert: this.invert, + match: this.matchProperty, + }, + }; + } } /** @@ -47,10 +296,23 @@ export interface GrpcRouteMatch { readonly serviceName: string; } +/** + * Base options for all route specs. + */ +export interface RouteSpecOptionsBase { + /** + * The priority for the route. Routes are matched based on the specified + * value, where 0 is the highest priority. + * + * @default - no particular priority + */ + readonly priority?: number; +} + /** * Properties specific for HTTP Based Routes */ -export interface HttpRouteSpecOptions { +export interface HttpRouteSpecOptions extends RouteSpecOptionsBase { /** * The criterion for determining a request match for this Route * @@ -149,7 +411,7 @@ export enum TcpRetryEvent { /** * Properties specific for a TCP Based Routes */ -export interface TcpRouteSpecOptions { +export interface TcpRouteSpecOptions extends RouteSpecOptionsBase { /** * List of targets that traffic is routed to when a request matches the route */ @@ -166,7 +428,7 @@ export interface TcpRouteSpecOptions { /** * Properties specific for a GRPC Based Routes */ -export interface GrpcRouteSpecOptions { +export interface GrpcRouteSpecOptions extends RouteSpecOptionsBase { /** * The criterion for determining a request match for this Route */ @@ -274,6 +536,14 @@ export interface RouteSpecConfig { * @default - no tcp spec */ readonly tcpRouteSpec?: CfnRoute.TcpRouteProperty; + + /** + * The priority for the route. Routes are matched based on the specified + * value, where 0 is the highest priority. + * + * @default - no particular priority + */ + readonly priority?: number; } /** @@ -317,24 +587,11 @@ export abstract class RouteSpec { } class HttpRouteSpec extends RouteSpec { - /** - * Type of route you are creating - */ + public readonly priority?: number; public readonly protocol: Protocol; - - /** - * The criteria for determining a request match - */ public readonly match?: HttpRouteMatch; - - /** - * The criteria for determining a timeout configuration - */ public readonly timeout?: HttpTimeout; - /** - * List of targets that traffic is routed to when a request matches the route - */ public readonly weightedTargets: WeightedTarget[]; /** @@ -348,6 +605,7 @@ class HttpRouteSpec extends RouteSpec { this.match = props.match; this.weightedTargets = props.weightedTargets; this.timeout = props.timeout; + this.priority = props.priority; if (props.retryPolicy) { const httpRetryEvents = props.retryPolicy.httpRetryEvents ?? []; @@ -365,7 +623,7 @@ class HttpRouteSpec extends RouteSpec { } } - public bind(_scope: Construct): RouteSpecConfig { + public bind(scope: Construct): RouteSpecConfig { const prefixPath = this.match ? this.match.prefixPath : '/'; if (prefixPath[0] != '/') { throw new Error(`Prefix Path must start with \'/\', got: ${prefixPath}`); @@ -377,11 +635,15 @@ class HttpRouteSpec extends RouteSpec { }, match: { prefix: prefixPath, + headers: this.match?.headers?.map(header => header.bind(scope).httpRouteHeader), + method: this.match?.method, + scheme: this.match?.protocol, }, timeout: renderTimeout(this.timeout), retryPolicy: this.retryPolicy ? renderHttpRetryPolicy(this.retryPolicy) : undefined, }; return { + priority: this.priority, httpRouteSpec: this.protocol === Protocol.HTTP ? httpConfig : undefined, http2RouteSpec: this.protocol === Protocol.HTTP2 ? httpConfig : undefined, }; @@ -389,6 +651,11 @@ class HttpRouteSpec extends RouteSpec { } class TcpRouteSpec extends RouteSpec { + /** + * The priority for the route. + */ + public readonly priority?: number; + /* * List of targets that traffic is routed to when a request matches the route */ @@ -403,10 +670,12 @@ class TcpRouteSpec extends RouteSpec { super(); this.weightedTargets = props.weightedTargets; this.timeout = props.timeout; + this.priority = props.priority; } public bind(_scope: Construct): RouteSpecConfig { return { + priority: this.priority, tcpRouteSpec: { action: { weightedTargets: renderWeightedTargets(this.weightedTargets), @@ -418,6 +687,11 @@ class TcpRouteSpec extends RouteSpec { } class GrpcRouteSpec extends RouteSpec { + /** + * The priority for the route. + */ + public readonly priority?: number; + public readonly weightedTargets: WeightedTarget[]; public readonly match: GrpcRouteMatch; public readonly timeout?: GrpcTimeout; @@ -432,6 +706,7 @@ class GrpcRouteSpec extends RouteSpec { this.weightedTargets = props.weightedTargets; this.match = props.match; this.timeout = props.timeout; + this.priority = props.priority; if (props.retryPolicy) { const grpcRetryEvents = props.retryPolicy.grpcRetryEvents ?? []; @@ -453,6 +728,7 @@ class GrpcRouteSpec extends RouteSpec { public bind(_scope: Construct): RouteSpecConfig { return { + priority: this.priority, grpcRouteSpec: { action: { weightedTargets: renderWeightedTargets(this.weightedTargets), diff --git a/packages/@aws-cdk/aws-appmesh/lib/route.ts b/packages/@aws-cdk/aws-appmesh/lib/route.ts index 7b9bd2aeb94d2..7800e3e08e53f 100644 --- a/packages/@aws-cdk/aws-appmesh/lib/route.ts +++ b/packages/@aws-cdk/aws-appmesh/lib/route.ts @@ -126,6 +126,7 @@ export class Route extends cdk.Resource implements IRoute { httpRoute: spec.httpRouteSpec, http2Route: spec.http2RouteSpec, grpcRoute: spec.grpcRouteSpec, + priority: spec.priority, }, }); diff --git a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.expected.json b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.expected.json index f951953924b44..c3139e2b75582 100644 --- a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.expected.json +++ b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.expected.json @@ -625,6 +625,124 @@ "RouteName": "route-3" } }, + "meshrouterroutematchingACC12F04": { + "Type": "AWS::AppMesh::Route", + "Properties": { + "MeshName": { + "Fn::GetAtt": [ + "meshACDFE68E", + "MeshName" + ] + }, + "Spec": { + "Http2Route": { + "Action": { + "WeightedTargets": [ + { + "VirtualNode": { + "Fn::GetAtt": [ + "meshnode3D2A19CF2", + "VirtualNodeName" + ] + }, + "Weight": 1 + } + ] + }, + "Match": { + "Headers": [ + { + "Invert": false, + "Match": { + "Exact": "application/json" + }, + "Name": "Content-Type" + }, + { + "Invert": false, + "Match": { + "Prefix": "application/json" + }, + "Name": "Content-Type" + }, + { + "Invert": false, + "Match": { + "Suffix": "application/json" + }, + "Name": "Content-Type" + }, + { + "Invert": false, + "Match": { + "Regex": "application/.*" + }, + "Name": "Content-Type" + }, + { + "Invert": false, + "Match": { + "Range": { + "End": 5, + "Start": 1 + } + }, + "Name": "Content-Type" + }, + { + "Invert": true, + "Match": { + "Exact": "application/json" + }, + "Name": "Content-Type" + }, + { + "Invert": true, + "Match": { + "Prefix": "application/json" + }, + "Name": "Content-Type" + }, + { + "Invert": true, + "Match": { + "Suffix": "application/json" + }, + "Name": "Content-Type" + }, + { + "Invert": true, + "Match": { + "Regex": "application/.*" + }, + "Name": "Content-Type" + }, + { + "Invert": true, + "Match": { + "Range": { + "End": 5, + "Start": 1 + } + }, + "Name": "Content-Type" + } + ], + "Method": "POST", + "Prefix": "/", + "Scheme": "https" + } + } + }, + "VirtualRouterName": { + "Fn::GetAtt": [ + "meshrouter81B8087E", + "VirtualRouterName" + ] + }, + "RouteName": "route-matching" + } + }, "meshrouterroutehttp2retryCC41345F": { "Type": "AWS::AppMesh::Route", "Properties": { @@ -676,6 +794,45 @@ "RouteName": "route-http2-retry" } }, + "meshrouterroute53F46B0FE": { + "Type": "AWS::AppMesh::Route", + "Properties": { + "MeshName": { + "Fn::GetAtt": [ + "meshACDFE68E", + "MeshName" + ] + }, + "Spec": { + "Http2Route": { + "Action": { + "WeightedTargets": [ + { + "VirtualNode": { + "Fn::GetAtt": [ + "meshnode2092BA426", + "VirtualNodeName" + ] + }, + "Weight": 1 + } + ] + }, + "Match": { + "Prefix": "/" + } + }, + "Priority": 10 + }, + "VirtualRouterName": { + "Fn::GetAtt": [ + "meshrouter81B8087E", + "VirtualRouterName" + ] + }, + "RouteName": "route-5" + } + }, "meshrouterroutegrpcretry9BEB798A": { "Type": "AWS::AppMesh::Route", "Properties": { diff --git a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts index c1e909e38d75b..4b62e8e12ee30 100644 --- a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts +++ b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts @@ -140,6 +140,29 @@ router.addRoute('route-3', { }), }); +router.addRoute('route-matching', { + routeSpec: appmesh.RouteSpec.http2({ + weightedTargets: [{ virtualNode: node3 }], + match: { + prefixPath: '/', + method: appmesh.HttpRouteMatchMethod.POST, + protocol: appmesh.HttpRouteProtocol.HTTPS, + headers: [ + appmesh.HttpHeaderMatch.valueIs('Content-Type', 'application/json'), + appmesh.HttpHeaderMatch.valueStartsWith('Content-Type', 'application/json'), + appmesh.HttpHeaderMatch.valueEndsWith('Content-Type', 'application/json'), + appmesh.HttpHeaderMatch.valueMatchesRegex('Content-Type', 'application/.*'), + appmesh.HttpHeaderMatch.valuesIsInRange('Content-Type', 1, 5), + appmesh.HttpHeaderMatch.valueIsNot('Content-Type', 'application/json'), + appmesh.HttpHeaderMatch.valueDoesNotStartWith('Content-Type', 'application/json'), + appmesh.HttpHeaderMatch.valueDoesNotEndWith('Content-Type', 'application/json'), + appmesh.HttpHeaderMatch.valueDoesNotMatchRegex('Content-Type', 'application/.*'), + appmesh.HttpHeaderMatch.valuesIsNotInRange('Content-Type', 1, 5), + ], + }, + }), +}); + router.addRoute('route-http2-retry', { routeSpec: appmesh.RouteSpec.http2({ weightedTargets: [{ virtualNode: node3 }], @@ -152,6 +175,13 @@ router.addRoute('route-http2-retry', { }), }); +router.addRoute('route-5', { + routeSpec: appmesh.RouteSpec.http2({ + priority: 10, + weightedTargets: [{ virtualNode: node2 }], + }), +}); + router.addRoute('route-grpc-retry', { routeSpec: appmesh.RouteSpec.grpc({ weightedTargets: [{ virtualNode: node3 }], diff --git a/packages/@aws-cdk/aws-appmesh/test/test.route.ts b/packages/@aws-cdk/aws-appmesh/test/test.route.ts index 43c2d942a669b..b3c1ae674a6f4 100644 --- a/packages/@aws-cdk/aws-appmesh/test/test.route.ts +++ b/packages/@aws-cdk/aws-appmesh/test/test.route.ts @@ -576,6 +576,278 @@ export = { }, }, + 'should match routes based on headers'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + router.addRoute('route', { + routeSpec: appmesh.RouteSpec.http2({ + weightedTargets: [{ virtualNode }], + match: { + prefixPath: '/', + headers: [ + appmesh.HttpHeaderMatch.valueIs('Content-Type', 'application/json'), + appmesh.HttpHeaderMatch.valueIsNot('Content-Type', 'text/html'), + appmesh.HttpHeaderMatch.valueStartsWith('Content-Type', 'application/'), + appmesh.HttpHeaderMatch.valueDoesNotStartWith('Content-Type', 'text/'), + appmesh.HttpHeaderMatch.valueEndsWith('Content-Type', '/json'), + appmesh.HttpHeaderMatch.valueDoesNotEndWith('Content-Type', '/json+foobar'), + appmesh.HttpHeaderMatch.valueMatchesRegex('Content-Type', 'application/.*'), + appmesh.HttpHeaderMatch.valueDoesNotMatchRegex('Content-Type', 'text/.*'), + appmesh.HttpHeaderMatch.valuesIsInRange('Max-Forward', 1, 5), + appmesh.HttpHeaderMatch.valuesIsNotInRange('Max-Forward', 1, 5), + ], + }, + }), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + Http2Route: { + Match: { + Prefix: '/', + Headers: [ + { + Invert: false, + Match: { Exact: 'application/json' }, + Name: 'Content-Type', + }, + { + Invert: true, + Match: { Exact: 'text/html' }, + Name: 'Content-Type', + }, + { + Invert: false, + Match: { Prefix: 'application/' }, + Name: 'Content-Type', + }, + { + Invert: true, + Match: { Prefix: 'text/' }, + Name: 'Content-Type', + }, + { + Invert: false, + Match: { Suffix: '/json' }, + Name: 'Content-Type', + }, + { + Invert: true, + Match: { Suffix: '/json+foobar' }, + Name: 'Content-Type', + }, + { + Invert: false, + Match: { Regex: 'application/.*' }, + Name: 'Content-Type', + }, + { + Invert: true, + Match: { Regex: 'text/.*' }, + Name: 'Content-Type', + }, + { + Invert: false, + Match: { + Range: { + End: 5, + Start: 1, + }, + }, + Name: 'Max-Forward', + }, + { + Invert: true, + Match: { + Range: { + End: 5, + Start: 1, + }, + }, + Name: 'Max-Forward', + }, + ], + }, + }, + }, + })); + + test.done(); + }, + + 'should match routes based on method'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + router.addRoute('route', { + routeSpec: appmesh.RouteSpec.http2({ + weightedTargets: [{ virtualNode }], + match: { + prefixPath: '/', + method: appmesh.HttpRouteMatchMethod.GET, + }, + }), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + Http2Route: { + Match: { + Prefix: '/', + Method: 'GET', + }, + }, + }, + })); + + test.done(); + }, + + 'should match routes based on scheme'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + router.addRoute('route', { + routeSpec: appmesh.RouteSpec.http2({ + weightedTargets: [{ virtualNode }], + match: { + prefixPath: '/', + protocol: appmesh.HttpRouteProtocol.HTTP, + }, + }), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + Http2Route: { + Match: { + Prefix: '/', + Scheme: 'http', + }, + }, + }, + })); + + test.done(); + }, + + 'should allow route priority'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + router.addRoute('http2', { + routeSpec: appmesh.RouteSpec.http2({ + priority: 0, + weightedTargets: [{ virtualNode }], + }), + }); + router.addRoute('http', { + routeSpec: appmesh.RouteSpec.http({ + priority: 10, + weightedTargets: [{ virtualNode }], + }), + }); + router.addRoute('grpc', { + routeSpec: appmesh.RouteSpec.grpc({ + priority: 20, + weightedTargets: [{ virtualNode }], + match: { + serviceName: 'test', + }, + }), + }); + router.addRoute('tcp', { + routeSpec: appmesh.RouteSpec.tcp({ + priority: 30, + weightedTargets: [{ virtualNode }], + }), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + Priority: 0, + Http2Route: {}, + }, + })); + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + Priority: 10, + HttpRoute: {}, + }, + })); + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + Priority: 20, + GrpcRoute: {}, + }, + })); + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + Priority: 30, + TcpRoute: {}, + }, + })); + + test.done(); + }, + 'Can import Routes using an ARN'(test: Test) { const app = new cdk.App(); // GIVEN From d3f428435976c55ca950279cfc841665fd504370 Mon Sep 17 00:00:00 2001 From: Alex Johnson Date: Thu, 11 Mar 2021 16:53:55 -0800 Subject: [PATCH 13/14] fix(appmesh): Move Client Policy from Virtual Service to backend structure (#12943) @sshver: > Client Policies are inherently not related to the Virtual Service. It should be thought of as the client (the VN) telling envoy what connections they want to allow to the server (the Virtual Service). The server shouldn't be the one to define what policies are used to enforce connections with itself. ## Description of changes I refactored the client policy from Virtual Service to a separate backend structure. This mirrors how our API is designed. Also ran `npm run lint -- --fix` and removed some comments to fix lint warnings. ```ts /* Old backend defaults */ backendsDefaultClientPolicy: appmesh.ClientPolicy.fileTrust({ certificateChain: 'path-to-certificate', }), /* result of this PR */ backendDefaults: { clientPolicy: appmesh.ClientPolicy.fileTrust({ certificateChain: 'path-to-certificate', }), }, ``` ```ts /* Old Virtual Service with client policy */ const service1 = new appmesh.VirtualService(stack, 'service-1', { virtualServiceName: 'service1.domain.local', virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), clientPolicy: appmesh.ClientPolicy.fileTrust({ certificateChain: 'path-to-certificate', ports: [8080, 8081], }), }); /* result of this PR; client policy is defined in the Virtual Node */ const service1 = new appmesh.VirtualService(stack, 'service-1', { virtualServiceName: 'service1.domain.local', virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), }); const node = new appmesh.VirtualNode(stack, 'test-node', { mesh, serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), }); node.addBackend({ virtualService: service1, clientPolicy: appmesh.ClientPolicy.fileTrust({ certificateChain: 'path-to-certificate', ports: [8080, 8081], }), }); ``` BREAKING CHANGE: Backend, backend default and Virtual Service client policies structures are being altered * **appmesh**: you must use the backend default interface to define backend defaults in `VirtualGateway`. The property name also changed from `backendsDefaultClientPolicy` to `backendDefaults` * **appmesh**: you must use the backend default interface to define backend defaults in `VirtualNode`, (the property name also changed from `backendsDefaultClientPolicy` to `backendDefaults`), and the `Backend` class to define a backend * **appmesh**: you can no longer attach a client policy to a `VirtualService` Resolves #11996 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../lib/extensions/appmesh.ts | 2 +- packages/@aws-cdk/aws-appmesh/README.md | 24 +++--- .../aws-appmesh/lib/shared-interfaces.ts | 79 +++++++++++++++++++ .../aws-appmesh/lib/virtual-gateway.ts | 11 ++- .../@aws-cdk/aws-appmesh/lib/virtual-node.ts | 23 +++--- .../aws-appmesh/lib/virtual-service.ts | 24 ------ .../@aws-cdk/aws-appmesh/test/integ.mesh.ts | 35 ++++---- .../aws-appmesh/test/test.health-check.ts | 2 - .../@aws-cdk/aws-appmesh/test/test.mesh.ts | 4 +- .../aws-appmesh/test/test.virtual-gateway.ts | 8 +- .../aws-appmesh/test/test.virtual-node.ts | 21 ++--- .../aws-appmesh/test/test.virtual-router.ts | 18 ++--- 12 files changed, 153 insertions(+), 98 deletions(-) diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/appmesh.ts b/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/appmesh.ts index 7749683fb4235..95220dc1ea3b4 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/appmesh.ts +++ b/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/appmesh.ts @@ -347,7 +347,7 @@ export class AppMeshExtension extends ServiceExtension { // Next update the app mesh config so that the local Envoy // proxy on this service knows how to route traffic to // nodes from the other service. - this.virtualNode.addBackend(otherAppMesh.virtualService); + this.virtualNode.addBackend(appmesh.Backend.virtualService(otherAppMesh.virtualService)); } private routeSpec(weightedTargets: appmesh.WeightedTarget[], serviceName: string): appmesh.RouteSpec { diff --git a/packages/@aws-cdk/aws-appmesh/README.md b/packages/@aws-cdk/aws-appmesh/README.md index 63203d6b365d1..678bbe22a2c20 100644 --- a/packages/@aws-cdk/aws-appmesh/README.md +++ b/packages/@aws-cdk/aws-appmesh/README.md @@ -186,9 +186,11 @@ const node = new VirtualNode(this, 'node', { idle: cdk.Duration.seconds(5), }, })], - backendsDefaultClientPolicy: appmesh.ClientPolicy.fileTrust({ - certificateChain: '/keys/local_cert_chain.pem', - }), + backendDefaults: { + clientPolicy: appmesh.ClientPolicy.fileTrust({ + certificateChain: '/keys/local_cert_chain.pem', + }), + }, accessLog: appmesh.AccessLog.fromFilePath('/dev/stdout'), }); @@ -230,14 +232,14 @@ const virtualService = new appmesh.VirtualService(stack, 'service-1', { }), }); -node.addBackend(virtualService); +node.addBackend(appmesh.Backend.virtualService(virtualService)); ``` The `listeners` property can be left blank and added later with the `node.addListener()` method. The `healthcheck` and `timeout` properties are optional but if specifying a listener, the `port` must be added. The `backends` property can be added with `node.addBackend()`. We define a virtual service and add it to the virtual node to allow egress traffic to other node. -The `backendsDefaultClientPolicy` property are added to the node while creating the virtual node. These are virtual node's service backends client policy defaults. +The `backendDefaults` property are added to the node while creating the virtual node. These are virtual node's default settings for all backends. ## Adding TLS to a listener @@ -437,10 +439,12 @@ const gateway = new appmesh.VirtualGateway(stack, 'gateway', { interval: cdk.Duration.seconds(10), }, })], - backendsDefaultClientPolicy: appmesh.ClientPolicy.acmTrust({ - certificateAuthorities: [acmpca.CertificateAuthority.fromCertificateAuthorityArn(stack, 'certificate', certificateAuthorityArn)], - ports: [8080, 8081], - }), + backendDefaults: { + clientPolicy: appmesh.ClientPolicy.acmTrust({ + certificateAuthorities: [acmpca.CertificateAuthority.fromCertificateAuthorityArn(stack, 'certificate', certificateAuthorityArn)], + ports: [8080, 8081], + }), + }, accessLog: appmesh.AccessLog.fromFilePath('/dev/stdout'), virtualGatewayName: 'virtualGateway', }); @@ -464,7 +468,7 @@ const gateway = mesh.addVirtualGateway('gateway', { The listeners field can be omitted which will default to an HTTP Listener on port 8080. A gateway route can be added using the `gateway.addGatewayRoute()` method. -The `backendsDefaultClientPolicy` property are added to the node while creating the virtual gateway. These are virtual gateway's service backends client policy defaults. +The `backendDefaults` property is added to the node while creating the virtual gateway. These are virtual gateway's default settings for all backends. ## Adding a Gateway Route diff --git a/packages/@aws-cdk/aws-appmesh/lib/shared-interfaces.ts b/packages/@aws-cdk/aws-appmesh/lib/shared-interfaces.ts index 831db66e49e0c..007f67c4a7a9b 100644 --- a/packages/@aws-cdk/aws-appmesh/lib/shared-interfaces.ts +++ b/packages/@aws-cdk/aws-appmesh/lib/shared-interfaces.ts @@ -1,5 +1,7 @@ import * as cdk from '@aws-cdk/core'; import { CfnVirtualGateway, CfnVirtualNode } from './appmesh.generated'; +import { ClientPolicy } from './client-policy'; +import { IVirtualService } from './virtual-service'; // keep this import separate from other imports to reduce chance for merge conflicts with v2-main // eslint-disable-next-line no-duplicate-imports, import/order @@ -194,3 +196,80 @@ class FileAccessLog extends AccessLog { } } +/** + * Represents the properties needed to define backend defaults + */ +export interface BackendDefaults { + /** + * Client policy for backend defaults + * + * @default none + */ + readonly clientPolicy?: ClientPolicy; +} + +/** + * Represents the properties needed to define a Virtual Service backend + */ +export interface VirtualServiceBackendOptions { + + /** + * Client policy for the backend + * + * @default none + */ + readonly clientPolicy?: ClientPolicy; +} + +/** + * Properties for a backend + */ +export interface BackendConfig { + /** + * Config for a Virtual Service backend + */ + readonly virtualServiceBackend: CfnVirtualNode.BackendProperty; +} + + +/** + * Contains static factory methods to create backends + */ +export abstract class Backend { + /** + * Construct a Virtual Service backend + */ + public static virtualService(virtualService: IVirtualService, props: VirtualServiceBackendOptions = {}): Backend { + return new VirtualServiceBackend(virtualService, props.clientPolicy); + } + + /** + * Return backend config + */ + public abstract bind(_scope: Construct): BackendConfig; +} + +/** + * Represents the properties needed to define a Virtual Service backend + */ +class VirtualServiceBackend extends Backend { + + constructor (private readonly virtualService: IVirtualService, + private readonly clientPolicy: ClientPolicy | undefined) { + super(); + } + + /** + * Return config for a Virtual Service backend + */ + public bind(_scope: Construct): BackendConfig { + return { + virtualServiceBackend: { + virtualService: { + virtualServiceName: this.virtualService.virtualServiceName, + clientPolicy: this.clientPolicy?.bind(_scope).clientPolicy, + }, + }, + }; + } +} diff --git a/packages/@aws-cdk/aws-appmesh/lib/virtual-gateway.ts b/packages/@aws-cdk/aws-appmesh/lib/virtual-gateway.ts index 1e1144fed1038..d2f0a873a0849 100644 --- a/packages/@aws-cdk/aws-appmesh/lib/virtual-gateway.ts +++ b/packages/@aws-cdk/aws-appmesh/lib/virtual-gateway.ts @@ -1,10 +1,9 @@ import * as cdk from '@aws-cdk/core'; import { Construct } from 'constructs'; import { CfnVirtualGateway } from './appmesh.generated'; -import { ClientPolicy } from './client-policy'; import { GatewayRoute, GatewayRouteBaseProps } from './gateway-route'; import { IMesh, Mesh } from './mesh'; -import { AccessLog } from './shared-interfaces'; +import { AccessLog, BackendDefaults } from './shared-interfaces'; import { VirtualGatewayListener, VirtualGatewayListenerConfig } from './virtual-gateway-listener'; /** @@ -66,7 +65,7 @@ export interface VirtualGatewayBaseProps { * * @default - No Config */ - readonly backendsDefaultClientPolicy?: ClientPolicy; + readonly backendDefaults?: BackendDefaults; } /** @@ -180,7 +179,11 @@ export class VirtualGateway extends VirtualGatewayBase { meshName: this.mesh.meshName, spec: { listeners: this.listeners.map(listener => listener.listener), - backendDefaults: props.backendsDefaultClientPolicy?.bind(this), + backendDefaults: props.backendDefaults !== undefined + ? { + clientPolicy: props.backendDefaults?.clientPolicy?.bind(this).clientPolicy, + } + : undefined, logging: accessLogging !== undefined ? { accessLog: accessLogging.virtualGatewayAccessLog, } : undefined, diff --git a/packages/@aws-cdk/aws-appmesh/lib/virtual-node.ts b/packages/@aws-cdk/aws-appmesh/lib/virtual-node.ts index 2cf56c74631a2..60ca92bb142ca 100644 --- a/packages/@aws-cdk/aws-appmesh/lib/virtual-node.ts +++ b/packages/@aws-cdk/aws-appmesh/lib/virtual-node.ts @@ -1,12 +1,10 @@ import * as cdk from '@aws-cdk/core'; import { Construct } from 'constructs'; import { CfnVirtualNode } from './appmesh.generated'; -import { ClientPolicy } from './client-policy'; import { IMesh, Mesh } from './mesh'; import { ServiceDiscovery } from './service-discovery'; -import { AccessLog } from './shared-interfaces'; +import { AccessLog, BackendDefaults, Backend } from './shared-interfaces'; import { VirtualNodeListener, VirtualNodeListenerConfig } from './virtual-node-listener'; -import { IVirtualService } from './virtual-service'; /** * Interface which all VirtualNode based classes must implement @@ -61,7 +59,7 @@ export interface VirtualNodeBaseProps { * * @default - No backends */ - readonly backends?: IVirtualService[]; + readonly backends?: Backend[]; /** * Initial listener for the virtual node @@ -82,7 +80,7 @@ export interface VirtualNodeBaseProps { * * @default - No Config */ - readonly backendsDefaultClientPolicy?: ClientPolicy; + readonly backendDefaults?: BackendDefaults; } /** @@ -185,7 +183,11 @@ export class VirtualNode extends VirtualNodeBase { spec: { backends: cdk.Lazy.anyValue({ produce: () => this.backends }, { omitEmptyArray: true }), listeners: cdk.Lazy.anyValue({ produce: () => this.listeners.map(listener => listener.listener) }, { omitEmptyArray: true }), - backendDefaults: props.backendsDefaultClientPolicy?.bind(this), + backendDefaults: props.backendDefaults !== undefined + ? { + clientPolicy: props.backendDefaults?.clientPolicy?.bind(this).clientPolicy, + } + : undefined, serviceDiscovery: { dns: serviceDiscovery?.dns, awsCloudMap: serviceDiscovery?.cloudmap, @@ -214,13 +216,8 @@ export class VirtualNode extends VirtualNodeBase { /** * Add a Virtual Services that this node is expected to send outbound traffic to */ - public addBackend(virtualService: IVirtualService) { - this.backends.push({ - virtualService: { - virtualServiceName: virtualService.virtualServiceName, - clientPolicy: virtualService.clientPolicy?.bind(this).clientPolicy, - }, - }); + public addBackend(backend: Backend) { + this.backends.push(backend.bind(this).virtualServiceBackend); } } diff --git a/packages/@aws-cdk/aws-appmesh/lib/virtual-service.ts b/packages/@aws-cdk/aws-appmesh/lib/virtual-service.ts index 5685b8b08c1f8..d41b47d554178 100644 --- a/packages/@aws-cdk/aws-appmesh/lib/virtual-service.ts +++ b/packages/@aws-cdk/aws-appmesh/lib/virtual-service.ts @@ -1,7 +1,6 @@ import * as cdk from '@aws-cdk/core'; import { Construct } from 'constructs'; import { CfnVirtualService } from './appmesh.generated'; -import { ClientPolicy } from './client-policy'; import { IMesh, Mesh } from './mesh'; import { IVirtualNode } from './virtual-node'; import { IVirtualRouter } from './virtual-router'; @@ -28,11 +27,6 @@ export interface IVirtualService extends cdk.IResource { * The Mesh which the VirtualService belongs to */ readonly mesh: IMesh; - - /** - * Client policy for this Virtual Service - */ - readonly clientPolicy?: ClientPolicy; } /** @@ -50,13 +44,6 @@ export interface VirtualServiceProps { */ readonly virtualServiceName?: string; - /** - * Client policy for this Virtual Service - * - * @default - none - */ - readonly clientPolicy?: ClientPolicy; - /** * The VirtualNode or VirtualRouter which the VirtualService uses as its provider */ @@ -90,7 +77,6 @@ export class VirtualService extends cdk.Resource implements IVirtualService { return new class extends cdk.Resource implements IVirtualService { readonly virtualServiceName = attrs.virtualServiceName; readonly mesh = attrs.mesh; - readonly clientPolicy = attrs.clientPolicy; readonly virtualServiceArn = cdk.Stack.of(this).formatArn({ service: 'appmesh', resource: `mesh/${attrs.mesh.meshName}/virtualService`, @@ -114,14 +100,11 @@ export class VirtualService extends cdk.Resource implements IVirtualService { */ public readonly mesh: IMesh; - public readonly clientPolicy?: ClientPolicy; - constructor(scope: Construct, id: string, props: VirtualServiceProps) { super(scope, id, { physicalName: props.virtualServiceName || cdk.Lazy.string({ produce: () => cdk.Names.uniqueId(this) }), }); - this.clientPolicy = props.clientPolicy; const providerConfig = props.virtualServiceProvider.bind(this); this.mesh = providerConfig.mesh; @@ -160,13 +143,6 @@ export interface VirtualServiceAttributes { * The Mesh which the VirtualService belongs to */ readonly mesh: IMesh; - - /** - * Client policy for this Virtual Service - * - * @default - none - */ - readonly clientPolicy?: ClientPolicy; } /** diff --git a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts index 4b62e8e12ee30..68709def26f95 100644 --- a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts +++ b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts @@ -36,16 +36,15 @@ const node = mesh.addVirtualNode('node', { path: '/check-path', }, })], - backends: [ - virtualService, - ], + backends: [appmesh.Backend.virtualService(virtualService)], }); -node.addBackend(new appmesh.VirtualService(stack, 'service-2', { - virtualServiceName: 'service2.domain.local', - virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), -}), -); +node.addBackend(appmesh.Backend.virtualService( + new appmesh.VirtualService(stack, 'service-2', { + virtualServiceName: 'service2.domain.local', + virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), + }), +)); router.addRoute('route-1', { routeSpec: appmesh.RouteSpec.http({ @@ -78,15 +77,17 @@ const node2 = mesh.addVirtualNode('node2', { unhealthyThreshold: 2, }, })], - backendsDefaultClientPolicy: appmesh.ClientPolicy.fileTrust({ - certificateChain: 'path/to/cert', - }), - backends: [ + backendDefaults: { + clientPolicy: appmesh.ClientPolicy.fileTrust({ + certificateChain: 'path/to/cert', + }), + }, + backends: [appmesh.Backend.virtualService( new appmesh.VirtualService(stack, 'service-3', { virtualServiceName: 'service3.domain.local', virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), }), - ], + )], }); const node3 = mesh.addVirtualNode('node3', { @@ -102,9 +103,11 @@ const node3 = mesh.addVirtualNode('node3', { unhealthyThreshold: 2, }, })], - backendsDefaultClientPolicy: appmesh.ClientPolicy.fileTrust({ - certificateChain: 'path-to-certificate', - }), + backendDefaults: { + clientPolicy: appmesh.ClientPolicy.fileTrust({ + certificateChain: 'path-to-certificate', + }), + }, accessLog: appmesh.AccessLog.fromFilePath('/dev/stdout'), }); diff --git a/packages/@aws-cdk/aws-appmesh/test/test.health-check.ts b/packages/@aws-cdk/aws-appmesh/test/test.health-check.ts index 7eec2b6d450b9..1ba7dc425da07 100644 --- a/packages/@aws-cdk/aws-appmesh/test/test.health-check.ts +++ b/packages/@aws-cdk/aws-appmesh/test/test.health-check.ts @@ -66,8 +66,6 @@ export = { // THEN test.doesNotThrow(() => toThrow(min)); test.doesNotThrow(() => toThrow(max)); - // falsy, falls back to portMapping.port - // test.throws(() => toThrow(min - 1), /below the minimum threshold/); test.throws(() => toThrow(max + 1), /above the maximum threshold/); test.done(); diff --git a/packages/@aws-cdk/aws-appmesh/test/test.mesh.ts b/packages/@aws-cdk/aws-appmesh/test/test.mesh.ts index ce50c1402a7c3..5c9c1cea7a9a1 100644 --- a/packages/@aws-cdk/aws-appmesh/test/test.mesh.ts +++ b/packages/@aws-cdk/aws-appmesh/test/test.mesh.ts @@ -270,9 +270,7 @@ export = { listeners: [appmesh.VirtualNodeListener.http({ port: 8080, })], - backends: [ - service1, - ], + backends: [appmesh.Backend.virtualService(service1)], }); // THEN diff --git a/packages/@aws-cdk/aws-appmesh/test/test.virtual-gateway.ts b/packages/@aws-cdk/aws-appmesh/test/test.virtual-gateway.ts index b9d3ed70cae43..25b7974983f2a 100644 --- a/packages/@aws-cdk/aws-appmesh/test/test.virtual-gateway.ts +++ b/packages/@aws-cdk/aws-appmesh/test/test.virtual-gateway.ts @@ -392,9 +392,11 @@ export = { new appmesh.VirtualGateway(stack, 'virtual-gateway', { virtualGatewayName: 'virtual-gateway', mesh: mesh, - backendsDefaultClientPolicy: appmesh.ClientPolicy.fileTrust({ - certificateChain: 'path-to-certificate', - }), + backendDefaults: { + clientPolicy: appmesh.ClientPolicy.fileTrust({ + certificateChain: 'path-to-certificate', + }), + }, }); // THEN diff --git a/packages/@aws-cdk/aws-appmesh/test/test.virtual-node.ts b/packages/@aws-cdk/aws-appmesh/test/test.virtual-node.ts index 4337973230854..c09bdef5badbd 100644 --- a/packages/@aws-cdk/aws-appmesh/test/test.virtual-node.ts +++ b/packages/@aws-cdk/aws-appmesh/test/test.virtual-node.ts @@ -29,10 +29,10 @@ export = { const node = new appmesh.VirtualNode(stack, 'test-node', { mesh, serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), - backends: [service1], + backends: [appmesh.Backend.virtualService(service1)], }); - node.addBackend(service2); + node.addBackend(appmesh.Backend.virtualService(service2)); // THEN expect(stack).to(haveResourceLike('AWS::AppMesh::VirtualNode', { @@ -272,10 +272,12 @@ export = { new appmesh.VirtualNode(stack, 'test-node', { mesh, serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), - backendsDefaultClientPolicy: appmesh.ClientPolicy.acmTrust({ - certificateAuthorities: [acmpca.CertificateAuthority.fromCertificateAuthorityArn(stack, 'certificate', certificateAuthorityArn)], - ports: [8080, 8081], - }), + backendDefaults: { + clientPolicy: appmesh.ClientPolicy.acmTrust({ + certificateAuthorities: [acmpca.CertificateAuthority.fromCertificateAuthorityArn(stack, 'certificate', certificateAuthorityArn)], + ports: [8080, 8081], + }), + }, }); // THEN @@ -320,13 +322,14 @@ export = { const service1 = new appmesh.VirtualService(stack, 'service-1', { virtualServiceName: 'service1.domain.local', virtualServiceProvider: appmesh.VirtualServiceProvider.none(mesh), + }); + + node.addBackend(appmesh.Backend.virtualService(service1, { clientPolicy: appmesh.ClientPolicy.fileTrust({ certificateChain: 'path-to-certificate', ports: [8080, 8081], }), - }); - - node.addBackend(service1); + })); // THEN expect(stack).to(haveResourceLike('AWS::AppMesh::VirtualNode', { diff --git a/packages/@aws-cdk/aws-appmesh/test/test.virtual-router.ts b/packages/@aws-cdk/aws-appmesh/test/test.virtual-router.ts index 2732adb4cba17..fef86e6bd7e7a 100644 --- a/packages/@aws-cdk/aws-appmesh/test/test.virtual-router.ts +++ b/packages/@aws-cdk/aws-appmesh/test/test.virtual-router.ts @@ -109,7 +109,7 @@ export = { listeners: [appmesh.VirtualNodeListener.http({ port: 8080, })], - backends: [service1], + backends: [appmesh.Backend.virtualService(service1)], }); router.addRoute('route-1', { @@ -182,27 +182,21 @@ export = { listeners: [appmesh.VirtualNodeListener.http({ port: 8080, })], - backends: [ - service1, - ], + backends: [appmesh.Backend.virtualService(service1)], }); const node2 = mesh.addVirtualNode('test-node2', { serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), listeners: [appmesh.VirtualNodeListener.http({ port: 8080, })], - backends: [ - service2, - ], + backends: [appmesh.Backend.virtualService(service2)], }); const node3 = mesh.addVirtualNode('test-node3', { serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), listeners: [appmesh.VirtualNodeListener.http({ port: 8080, })], - backends: [ - service1, - ], + backends: [appmesh.Backend.virtualService(service1)], }); router.addRoute('route-1', { @@ -340,9 +334,7 @@ export = { listeners: [appmesh.VirtualNodeListener.http({ port: 8080, })], - backends: [ - service1, - ], + backends: [appmesh.Backend.virtualService(service1)], }); router.addRoute('route-tcp-1', { From 278029f25b41d956091835364e5a8de91429712c Mon Sep 17 00:00:00 2001 From: Benura Abeywardena <43112139+BLasan@users.noreply.github.com> Date: Fri, 12 Mar 2021 15:34:56 +0530 Subject: [PATCH 14/14] fix(cloudwatch): cannot create Alarms from labeled metrics that start with a digit (#13560) fixes #13434 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts | 3 +-- .../aws-cloudwatch/test/integ.alarm-with-label.expected.json | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts b/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts index d8c93f66aa910..ef97dc3d79c7f 100644 --- a/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts +++ b/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts @@ -257,7 +257,6 @@ export class Alarm extends AlarmBase { return dispatchMetric(metric, { withStat(stat, conf) { self.validateMetricStat(stat, metric); - if (conf.renderingProperties?.label == undefined) { return dropUndefined({ dimensions: stat.dimensions, @@ -283,7 +282,7 @@ export class Alarm extends AlarmBase { stat: stat.statistic, unit: stat.unitFilter, }, - id: stat.metricName, + id: 'm1', label: conf.renderingProperties?.label, returnData: true, } as CfnAlarm.MetricDataQueryProperty, diff --git a/packages/@aws-cdk/aws-cloudwatch/test/integ.alarm-with-label.expected.json b/packages/@aws-cdk/aws-cloudwatch/test/integ.alarm-with-label.expected.json index 9ab6e14f29a6e..6ac734ed6e534 100644 --- a/packages/@aws-cdk/aws-cloudwatch/test/integ.alarm-with-label.expected.json +++ b/packages/@aws-cdk/aws-cloudwatch/test/integ.alarm-with-label.expected.json @@ -5,7 +5,7 @@ "Properties": { "Metrics": [ { - "Id": "Metric", + "Id": "m1", "Label": "Metric [AVG: ${AVG}]", "MetricStat": { "Metric": { @@ -28,7 +28,7 @@ "Properties": { "Metrics": [ { - "Id": "Metric", + "Id": "m1", "Label": "Metric [AVG: ${AVG}]", "MetricStat": { "Metric": {