diff --git a/src/decdk.ts b/src/decdk.ts index 2ca00921..66fd79fa 100644 --- a/src/decdk.ts +++ b/src/decdk.ts @@ -2,16 +2,26 @@ import * as cdk from 'aws-cdk-lib'; import chalk from 'chalk'; import yargs from 'yargs'; import { DeclarativeStack } from './declarative-stack'; +import { DeclarativeStackError } from './error-handling'; import { loadTypeSystem, readTemplate, stackNameFromFileName } from './util'; +let verbosity = 0; + async function main() { const argv = await yargs .usage( '$0 ', 'Synthesize a CDK stack from a declarative JSON or YAML template' ) + .option('verbose', { + alias: 'v', + type: 'count', + description: 'Show debug output. Repeat to increase verbosity.', + }) .positional('filename', { type: 'string', required: true }).argv; + verbosity = argv.verbose; + const templateFile = argv.filename; if (!templateFile) { throw new Error('filename is missing'); @@ -35,6 +45,9 @@ async function main() { } main().catch((e) => { + if (e instanceof DeclarativeStackError) { + e = e.toString(verbosity >= 1); + } // eslint-disable-next-line no-console console.error(chalk.red(e)); process.exit(1); diff --git a/src/declarative-stack.ts b/src/declarative-stack.ts index 9fee685f..e974724d 100644 --- a/src/declarative-stack.ts +++ b/src/declarative-stack.ts @@ -1,6 +1,7 @@ import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import * as reflect from 'jsii-reflect'; +import { AnnotationsContext, DeclarativeStackError } from './error-handling'; import { EvaluationContext, Evaluator } from './evaluate'; import { Template } from './parser/template'; import { TypedTemplate } from './type-resolution/template'; @@ -37,6 +38,7 @@ export class DeclarativeStack extends cdk.Stack { }; })(this, '$decdkAnalytics'); + const annotations = AnnotationsContext.root(); const context = new EvaluationContext({ stack: this, template, @@ -45,8 +47,12 @@ export class DeclarativeStack extends cdk.Stack { const ev = new Evaluator(context); _cwd(props.workingDirectory, () => { - ev.evaluateTemplate(); + ev.evaluateTemplate(annotations); }); + + if (annotations.hasErrors()) { + throw new DeclarativeStackError(annotations); + } } } diff --git a/src/error-handling/annotations.ts b/src/error-handling/annotations.ts new file mode 100644 index 00000000..eda8bd3b --- /dev/null +++ b/src/error-handling/annotations.ts @@ -0,0 +1,56 @@ +import { AnnotatedError } from './errors'; + +export class AnnotationsContext { + public static root(): AnnotationsContext { + return new AnnotationsContext(); + } + + public readonly children = new Array(); + public readonly path: Array = []; + public readonly parent?: AnnotationsContext; + private readonly _errorsStack: AnnotatedError[] = []; + + private constructor(parent?: AnnotationsContext, path?: string | number) { + if (parent) { + this.path.push(...parent.path); + this.parent = parent; + parent.children.push(this); + } + + if (path !== undefined) { + this.path.push(path); + } + } + + public get root(): boolean { + return !!this.parent; + } + + public hasErrors(): boolean { + return this.errors.length > 0; + } + + public get errors(): AnnotatedError[] { + return [...this._errorsStack, ...this.children.flatMap((c) => c.errors)]; + } + + public child(path: string | number): AnnotationsContext { + return new AnnotationsContext(this, path); + } + + public wrap(fn: (ctx: AnnotationsContext) => T): T | void { + try { + return fn(this); + } catch (error) { + this.error(error as any); + } + } + + public error(error: Error) { + this._errorsStack.push(new AnnotatedError(this.path, error)); + } + + public toString(printStackStrace = false) { + return this.errors.map((e) => e.toString(printStackStrace)).join('\n\n'); + } +} diff --git a/src/error-handling/errors.ts b/src/error-handling/errors.ts new file mode 100644 index 00000000..f5ebb60c --- /dev/null +++ b/src/error-handling/errors.ts @@ -0,0 +1,68 @@ +import { AnnotationsContext } from './annotations'; + +function indent(text: string, spaces = 4) { + const TAB = ' '.repeat(spaces); + return text + .split('\n') + .map((l) => TAB + l) + .join('\n'); +} + +export class DeclarativeStackError extends Error { + constructor(public readonly annotations: AnnotationsContext) { + super(); + this.name = 'DeclarativeStackError'; + this.message = this.toString(); + Object.setPrototypeOf(this, DeclarativeStackError.prototype); + } + + public toString(debug = false) { + return `${this.name}:\n\n${this.annotations.toString(debug)}`; + } +} + +/** + * Thrown by the Evaluator + */ +export class RuntimeError extends Error { + public static wrap(error: any) { + return new RuntimeError(error.message, error.stack); + } + + constructor(message: string, stack?: string) { + super(message); + this.name = 'RuntimeError'; + if (stack) { + this.stack = stack; + } + Object.setPrototypeOf(this, RuntimeError.prototype); + } +} + +/** + * Annotation any Error with additional info + */ +export class AnnotatedError { + constructor( + public readonly stack: Array, + public readonly error: Error + ) {} + + public toString(printStackStrace = false) { + const details = this.renderErrorDetails(printStackStrace); + return `[${this.error.name} at ${this.renderStack()}]\n${indent(details)}`; + } + + protected renderErrorDetails(printStackStrace = false): string { + if (printStackStrace) { + return this.error.stack ?? this.error.message; + } + return this.error.message; + } + + protected renderStack(): string { + return this.stack + .map((s) => (s.toString().includes('.') ? `"${s}"` : s)) + .join('.'); + } +} diff --git a/src/error-handling/index.ts b/src/error-handling/index.ts new file mode 100644 index 00000000..0ec7e4db --- /dev/null +++ b/src/error-handling/index.ts @@ -0,0 +1,3 @@ +export * from './annotations'; +export * from './errors'; +export * from './unparse'; diff --git a/src/error-handling/unparse.ts b/src/error-handling/unparse.ts new file mode 100644 index 00000000..c6e33ec9 --- /dev/null +++ b/src/error-handling/unparse.ts @@ -0,0 +1,28 @@ +import { + INTRINSIC_NAME_MAP, + TemplateExpression, + UserIntrinsicExpression, +} from '../parser/template'; + +export function unparseExpression(x: TemplateExpression): any { + switch (x.type) { + case 'string': + case 'number': + case 'boolean': + return x.value; + case 'null': + return null; + case 'array': + return x.array.map(unparseExpression); + case 'object': + return Object.fromEntries( + Object.entries(x.fields).map(([k, v]) => [k, unparseExpression(v)]) + ); + default: + return x; + } +} + +export function intrinsicToLongForm(fn: UserIntrinsicExpression['fn']): string { + return INTRINSIC_NAME_MAP?.[fn] ?? fn; +} diff --git a/src/evaluate/context.ts b/src/evaluate/context.ts index 10a5b42e..48dae0e9 100644 --- a/src/evaluate/context.ts +++ b/src/evaluate/context.ts @@ -1,5 +1,6 @@ import * as cdk from 'aws-cdk-lib'; import * as reflect from 'jsii-reflect'; +import { RuntimeError } from '../error-handling'; import { TypedTemplate } from '../type-resolution/template'; import { InstanceReference, Reference, References } from './references'; @@ -47,7 +48,7 @@ export class EvaluationContext { public reference(logicalId: string): Reference { const r = this.availableRefs.get(logicalId); if (!r) { - throw new Error(`No resource or parameter with name: ${logicalId}`); + throw new RuntimeError(`No such Resource or Parameter: ${logicalId}`); } return r; } @@ -55,7 +56,7 @@ export class EvaluationContext { public mapping(mappingName: string) { const map = this.template?.mappings?.get(mappingName); if (!map) { - throw new Error(`No such Mapping: ${mappingName}`); + throw new RuntimeError(`No such Mapping: ${mappingName}`); } return map; } @@ -63,7 +64,7 @@ export class EvaluationContext { public condition(conditionName: string) { const condition = this.template?.conditions?.get(conditionName); if (!condition) { - throw new Error(`No such condition: ${conditionName}`); + throw new RuntimeError(`No such condition: ${conditionName}`); } return condition; } @@ -84,7 +85,7 @@ export class EvaluationContext { } curr = curr[next]; if (!curr) { - throw new Error(`unable to resolve class ${className}`); + throw new RuntimeError(`Class not found: ${fqn}`); } } diff --git a/src/evaluate/evaluate.ts b/src/evaluate/evaluate.ts index b2f2db0f..07c8a653 100644 --- a/src/evaluate/evaluate.ts +++ b/src/evaluate/evaluate.ts @@ -9,10 +9,16 @@ import { Token, } from 'aws-cdk-lib'; import { Construct, IConstruct } from 'constructs'; +import { + AnnotationsContext, + RuntimeError, + intrinsicToLongForm, +} from '../error-handling'; import { SubFragment } from '../parser/private/sub'; import { assertString } from '../parser/private/types'; import { GetPropIntrinsic, + IntrinsicExpression, LazyLogicalId, RefIntrinsic, } from '../parser/template'; @@ -29,7 +35,10 @@ import { InstanceMethodCallExpression, StaticMethodCallExpression, } from '../type-resolution/callables'; -import { TypedTemplateExpression } from '../type-resolution/expression'; +import { + TypedArrayExpression, + TypedTemplateExpression, +} from '../type-resolution/expression'; import { ResolveReferenceExpression } from '../type-resolution/references'; import { EvaluationContext } from './context'; import { DeCDKCfnOutput } from './outputs'; @@ -45,96 +54,112 @@ import { export class Evaluator { constructor(public readonly context: EvaluationContext) {} - public evaluateTemplate() { - this.evaluateParameters(); - this.evaluateMetadata(); - this.evaluateRules(); - this.evaluateMappings(); - this.evaluateConditions(); - this.evaluateTransform(); - this.evaluateResources(); - this.evaluateOutputs(); - this.evaluateHooks(); + public evaluateTemplate(ctx: AnnotationsContext) { + this.evaluateParameters(ctx.child('Parameters')); + this.evaluateMetadata(ctx.child('Metadata')); + this.evaluateRules(ctx.child('Rules')); + this.evaluateMappings(ctx.child('Mappings')); + this.evaluateConditions(ctx.child('Conditions')); + this.evaluateTransform(ctx.child('Transform')); + this.evaluateResources(ctx.child('Resources')); + this.evaluateOutputs(ctx.child('Outputs')); + this.evaluateHooks(ctx.child('Hooks')); } - private evaluateMappings() { + private evaluateMappings(ctx: AnnotationsContext) { const scope = new Construct(this.context.stack, '$Mappings'); this.context.template.mappings.forEach((mapping, mapName) => - new cdk.CfnMapping(scope, mapName, { - mapping: mapping.toObject(), - }).overrideLogicalId(mapName) + ctx.child(mapName).wrap(() => { + new cdk.CfnMapping(scope, mapName, { + mapping: mapping.toObject(), + }).overrideLogicalId(mapName); + }) ); } - private evaluateParameters() { + private evaluateParameters(ctx: AnnotationsContext) { this.context.template.parameters.forEach((param, paramName) => { - new cdk.CfnParameter( - this.context.stack, - paramName, - param - ).overrideLogicalId(paramName); - this.context.addReference(new SimpleReference(paramName)); + ctx.child(paramName).wrap(() => { + new cdk.CfnParameter( + this.context.stack, + paramName, + param + ).overrideLogicalId(paramName); + this.context.addReference(new SimpleReference(paramName)); + }); }); } - private evaluateResources() { - this.context.template.resources.forEach((_logicalId, resource) => - this.evaluateResource(resource) + private evaluateResources(ctx: AnnotationsContext) { + this.context.template.resources.forEach((logicalId, resource) => + ctx.child(logicalId).wrap((c) => this.evaluateResource(resource, c)) ); } - private evaluateOutputs() { + private evaluateOutputs(ctx: AnnotationsContext) { const scope = new Construct(this.context.stack, '$Outputs'); this.context.template.outputs.forEach((output, outputId) => { - new DeCDKCfnOutput(scope, outputId, { - value: this.evaluate(output.value), - description: output.description, - exportName: output.exportName - ? this.evaluate(output.exportName) - : output.exportName, - condition: output.conditionName, - }).overrideLogicalId(outputId); + ctx.child(outputId).wrap(() => { + new DeCDKCfnOutput(scope, outputId, { + value: this.evaluate(output.value, ctx), + description: output.description, + exportName: output.exportName + ? this.evaluate(output.exportName, ctx) + : output.exportName, + condition: output.conditionName, + }).overrideLogicalId(outputId); + }); }); } - private evaluateTransform() { - this.context.template.transform.forEach((t) => { - this.context.stack.addTransform(t); + private evaluateTransform(ctx: AnnotationsContext) { + ctx.wrap(() => { + this.context.template.transform.forEach((t) => { + this.context.stack.addTransform(t); + }); }); } - private evaluateMetadata() { + private evaluateMetadata(ctx: AnnotationsContext) { this.context.template.metadata.forEach((v, k) => { - this.context.stack.addMetadata(k, v); + ctx.child(k).wrap(() => { + this.context.stack.addMetadata(k, v); + }); }); } - private evaluateRules() { + private evaluateRules(ctx: AnnotationsContext) { const scope = new Construct(this.context.stack, '$Rules'); this.context.template.rules.forEach((rule, name) => { - new CfnRule(scope, name, rule).overrideLogicalId(name); + ctx.child(name).wrap(() => { + new CfnRule(scope, name, rule).overrideLogicalId(name); + }); }); } - private evaluateHooks() { + private evaluateHooks(ctx: AnnotationsContext) { const scope = new Construct(this.context.stack, '$Hooks'); this.context.template.hooks.forEach((hook, name) => { - new CfnHook(scope, name, hook).overrideLogicalId(name); + ctx.child(name).wrap(() => { + new CfnHook(scope, name, hook).overrideLogicalId(name); + }); }); } - private evaluateConditions() { + private evaluateConditions(ctx: AnnotationsContext) { const scope = new Construct(this.context.stack, '$Conditions'); this.context.template.conditions.forEach((condition, logicalId) => { - const conditionFn = this.evaluate(condition); - new cdk.CfnCondition(scope, logicalId, { - expression: conditionFn, - }).overrideLogicalId(logicalId); + ctx.child(logicalId).wrap(() => { + const conditionFn = this.evaluate(condition, ctx); + new cdk.CfnCondition(scope, logicalId, { + expression: conditionFn, + }).overrideLogicalId(logicalId); + }); }); } - public evaluateResource(resource: ResourceLike) { - const construct = this.evaluate(resource); + public evaluateResource(resource: ResourceLike, ctx: AnnotationsContext) { + const construct = this.evaluate(resource, ctx); // If this is the result of a call to a method with no // return type (void), then there is nothing else to do here. @@ -143,7 +168,11 @@ export class Evaluator { this.applyTags(construct, resource.tags); this.applyDependsOn(construct, resource.dependsOn); if (isCdkConstructExpression(resource)) { - this.applyOverrides(construct, resource.overrides); + this.applyOverrides( + construct, + resource.overrides, + ctx.child('Overrides') + ); } this.context.addReference( @@ -163,126 +192,71 @@ export class Evaluator { return new ConstructReference(logicalId, value as Construct); } - public evaluate(x: TypedTemplateExpression): any { - const ev = this.evaluate.bind(this); - const maybeEv = (y?: TypedTemplateExpression): any => - y ? ev(y) : undefined; - - switch (x.type) { - case 'null': - return undefined; - case 'string': - case 'number': - case 'boolean': - return x.value; - case 'date': - return x.date; - case 'array': - return this.evaluateArray(x.array); - case 'struct': - case 'object': - return this.evaluateObject(x.fields); - case 'resolve-reference': - return this.resolveReference(x.reference); - case 'intrinsic': - switch (x.fn) { - case 'base64': - return this.fnBase64(assertString(ev(x.expression))); - case 'cidr': - return this.fnCidr(ev(x.ipBlock), ev(x.count), maybeEv(x.netMask)); - case 'findInMap': - return this.fnFindInMap( - assertString(ev(x.mappingName)), - assertString(ev(x.key1)), - assertString(ev(x.key2)) + public evaluate(x: TypedTemplateExpression, ctx: AnnotationsContext): any { + try { + switch (x.type) { + case 'null': + return undefined; + case 'string': + case 'number': + case 'boolean': + return x.value; + case 'date': + return x.date; + case 'array': + return this.evaluateArray(x.array, ctx); + case 'struct': + case 'object': + return this.evaluateObject(x.fields, ctx); + case 'resolve-reference': + return this.resolveReference(x.reference, ctx); + case 'intrinsic': + return this.evaluateIntrinsic(x, ctx); + case 'enum': + return this.enum(x.fqn, x.choice); + case 'staticProperty': + return this.enum(x.fqn, x.property); + case 'any': + return this.evaluate(x.value, ctx); + case 'void': + return; + case 'lazyResource': + return this.invoke(x.call, ctx.child('Call')); + case 'construct': + return this.initializeConstruct(x, ctx); + case 'cdkObject': + return this.initializeCdkObject(x, ctx); + case 'resource': + return this.initializeCfnResource(x, ctx); + case 'initializer': + return ctx + .child(x.fqn) + .wrap((innerCtx) => + this.initializer( + x.fqn, + this.evaluateArray(x.args.array, innerCtx) + ) ); - case 'getAtt': - return this.fnGetAtt(x.logicalId, assertString(ev(x.attribute))); - case 'getProp': - return this.fnGetProp(x.logicalId, assertString(x.property)); - case 'getAzs': - return this.fnGetAzs(assertString(ev(x.region))); - case 'if': - return this.fnIf(x.conditionName, x.then, x.else); - case 'importValue': - return this.fnImportValue(assertString(ev(x.export))); - case 'join': - return this.fnJoin(assertString(x.separator), ev(x.list)); - case 'ref': - return this.cfnRef(x.logicalId); - case 'select': - return this.fnSelect(ev(x.index), ev(x.objects)); - case 'split': - return this.fnSplit(x.separator, assertString(ev(x.value))); - case 'sub': - return this.fnSub( - x.fragments, - this.evaluateObject(x.additionalContext) - ); - case 'transform': - return this.fnTransform( - x.transformName, - this.evaluateObject(x.parameters) - ); - case 'and': - return this.fnAnd(x.operands.map(ev)); - case 'or': - return this.fnOr(x.operands.map(ev)); - case 'not': - return this.fnNot(ev(x.operand)); - case 'equals': - return this.fnEquals(ev(x.value1), ev(x.value2)); - case 'args': - return this.evaluateArray(x.array); - case 'lazyLogicalId': - return this.lazyLogicalId(x); - case 'length': - return this.fnLength(ev(x.list)); - case 'toJsonString': - return this.toJsonString(this.evaluateObject(x.value)); - } - case 'enum': - return this.enum(x.fqn, x.choice); - case 'staticProperty': - return this.enum(x.fqn, x.property); - case 'any': - return ev(x.value); - case 'void': - return; - case 'lazyResource': - return this.invoke(x.call); - case 'construct': - return this.initializeConstruct(x, ev); - case 'cdkObject': - return this.initializeCdkObject(x, ev); - case 'resource': - return this.initializeCfnResource(x, ev); - case 'initializer': - return this.initializer(x.fqn, this.evaluateArray(x.args.array)); - case 'staticMethodCall': - return this.invokeStaticMethod( - x.fqn, - x.method, - this.evaluateArray(x.args.array) - ); + case 'staticMethodCall': + return this.invoke(x, ctx); + } + } catch (error) { + ctx.error(RuntimeError.wrap(error)); } } - protected initializeCfnResource( - x: CfnResourceNode, - ev: (x: TypedTemplateExpression) => any - ) { + protected initializeCfnResource(x: CfnResourceNode, ctx: AnnotationsContext) { const resource = this.initializer(x.fqn, [ this.context.stack, x.logicalId, - ev(x.props), + this.evaluate(x.props, ctx.child('Properties')), ]) as CfnResource; resource.cfnOptions.creationPolicy = x.creationPolicy - ? ev(x.creationPolicy) + ? this.evaluate(x.creationPolicy, ctx.child('CreationPolicy')) : undefined; resource.cfnOptions.updatePolicy = x.updatePolicy - ? ev(x.updatePolicy) + ? this.evaluate(x.updatePolicy, ctx.child('UpdatePolicy')) : undefined; resource.cfnOptions.metadata = x.metadata; resource.cfnOptions.updateReplacePolicy = @@ -292,22 +266,18 @@ export class Evaluator { return resource; } - protected initializeConstruct( - x: CdkConstruct, - ev: (x: TypedTemplateExpression) => any - ) { + protected initializeConstruct(x: CdkConstruct, ctx: AnnotationsContext) { return this.initializer(x.fqn, [ this.context.stack, x.logicalId, - ev(x.props), + this.evaluate(x.props, ctx.child('Properties')), ]); } - protected initializeCdkObject( - x: CdkObject, - ev: (x: TypedTemplateExpression) => any - ) { - return this.initializer(x.fqn, [ev(x.props)]); + protected initializeCdkObject(x: CdkObject, ctx: AnnotationsContext) { + return this.initializer(x.fqn, [ + this.evaluate(x.props, ctx.child('Properties')), + ]); } protected lazyLogicalId(x: LazyLogicalId) { @@ -326,20 +296,88 @@ export class Evaluator { } public evaluateObject( - xs: Record + xs: Record, + ctx: AnnotationsContext ): Record { return Object.fromEntries( - Object.entries(xs).map(([k, v]) => [k, this.evaluate(v)]) + Object.entries(xs).map(([k, v]) => [k, this.evaluate(v, ctx.child(k))]) ); } - public evaluateArray(xs: TypedTemplateExpression[]) { - return xs.map(this.evaluate.bind(this)); + public evaluateArray(xs: TypedTemplateExpression[], ctx: AnnotationsContext) { + return xs.map((x, idx) => this.evaluate(x, ctx.child(idx))); } - public evaluateCondition(conditionName: string) { + public evaluateIntrinsic(x: IntrinsicExpression, ctx: AnnotationsContext) { + if (x.fn === 'lazyLogicalId') { + return this.lazyLogicalId(x); + } + + return ctx.child(intrinsicToLongForm(x.fn)).wrap((intrinsicCtx) => { + const ev = (y: TypedTemplateExpression) => this.evaluate(y, intrinsicCtx); + const maybeEv = (y?: TypedTemplateExpression): any => + y ? ev(y) : undefined; + + switch (x.fn) { + case 'base64': + return this.fnBase64(assertString(ev(x.expression))); + case 'cidr': + return this.fnCidr(ev(x.ipBlock), ev(x.count), maybeEv(x.netMask)); + case 'findInMap': + return this.fnFindInMap( + assertString(ev(x.mappingName)), + assertString(ev(x.key1)), + assertString(ev(x.key2)) + ); + case 'getAtt': + return this.fnGetAtt(x.logicalId, assertString(ev(x.attribute))); + case 'getProp': + return this.fnGetProp(x.logicalId, assertString(x.property)); + case 'getAzs': + return this.fnGetAzs(assertString(ev(x.region))); + case 'if': + return this.fnIf(x.conditionName, x.then, x.else, intrinsicCtx); + case 'importValue': + return this.fnImportValue(assertString(ev(x.export))); + case 'join': + return this.fnJoin(assertString(x.separator), ev(x.list)); + case 'ref': + return this.cfnRef(x.logicalId); + case 'select': + return this.fnSelect(ev(x.index), ev(x.objects)); + case 'split': + return this.fnSplit(x.separator, assertString(ev(x.value))); + case 'sub': + return this.fnSub( + x.fragments, + this.evaluateObject(x.additionalContext, intrinsicCtx) + ); + case 'transform': + return this.fnTransform( + x.transformName, + this.evaluateObject(x.parameters, intrinsicCtx) + ); + case 'and': + return this.fnAnd(x.operands.map((o) => ev(o))); + case 'or': + return this.fnOr(x.operands.map((o) => ev(o))); + case 'not': + return this.fnNot(ev(x.operand)); + case 'equals': + return this.fnEquals(ev(x.value1), ev(x.value2)); + case 'args': + return this.evaluateArray(x.array, intrinsicCtx); + case 'length': + return this.fnLength(ev(x.list)); + case 'toJsonString': + return this.toJsonString(this.evaluateObject(x.value, intrinsicCtx)); + } + }); + } + + public evaluateCondition(conditionName: string, ctx: AnnotationsContext) { const condition = this.context.condition(conditionName); - const result = this.evaluate(condition); + const result = this.evaluate(condition, ctx); if (typeof result !== 'boolean') { throw new Error( `Condition does not evaluate to boolean: ${JSON.stringify(result)}` @@ -349,25 +387,47 @@ export class Evaluator { } protected invoke( - call: StaticMethodCallExpression | InstanceMethodCallExpression + call: StaticMethodCallExpression | InstanceMethodCallExpression, + ctx: AnnotationsContext ) { - const parameters = this.evaluateArray(call.args.array); + const evalParams = ( + args: TypedArrayExpression, + innerCtx: AnnotationsContext + ) => this.evaluateArray(args.array, innerCtx.child('CDK::Args')); + + if (call.type === 'staticMethodCall') { + return ctx.child(`${call.fqn}.${call.method}`).wrap((innerCtx) => { + return this.invokeStaticMethod( + call.fqn, + call.method, + evalParams(call.args, innerCtx) + ); + }); + } - return call.type === 'staticMethodCall' - ? this.invokeStaticMethod(call.fqn, call.method, parameters) - : this.invokeInstanceMethod(call.target, call.method, parameters); + return ctx + .child(`${call.target.reference}.${call.method}`) + .wrap((innerCtx) => { + return this.invokeInstanceMethod( + call.target, + call.method, + evalParams(call.args, innerCtx), + innerCtx + ); + }); } private invokeInstanceMethod( target: ResolveReferenceExpression, method: string, - parameters: any[] + parameters: any[], + ctx: AnnotationsContext ) { - const instance = this.resolveReference(target.reference); + const instance = this.resolveReference(target.reference, ctx); return instance[method](...parameters); } - protected invokeStaticMethod( + private invokeStaticMethod( fqn: string, method: string, parameters: unknown[] @@ -429,12 +489,13 @@ export class Evaluator { protected fnIf( conditionName: string, ifYes: TypedTemplateExpression, - ifNo: TypedTemplateExpression + ifNo: TypedTemplateExpression, + ctx: AnnotationsContext ) { return cdk.Fn.conditionIf( conditionName, - this.evaluate(ifYes), - this.evaluate(ifNo) + this.evaluate(ifYes, ctx), + this.evaluate(ifNo, ctx) ); } @@ -446,20 +507,25 @@ export class Evaluator { return cdk.Fn.join(separator, array); } - protected resolveReference(intrinsic: RefIntrinsic | GetPropIntrinsic) { + protected resolveReference( + intrinsic: RefIntrinsic | GetPropIntrinsic, + ctx: AnnotationsContext + ) { const { logicalId, fn } = intrinsic; - const c = this.context.reference(logicalId); - if (fn !== 'ref') { - return this.evaluate(intrinsic); + return this.evaluateIntrinsic(intrinsic, ctx); } - if (!c.instance) { - return this.cfnRef(logicalId); - } + return ctx.child('Ref').wrap(() => { + const c = this.context.reference(logicalId); + + if (!c.instance) { + return this.cfnRef(logicalId); + } - return c.instance; + return c.instance; + }); } protected cfnRef(logicalId: string) { @@ -565,9 +631,10 @@ export class Evaluator { protected applyOverrides( resource: IConstruct, - overrides: ResourceOverride[] + overrides: ResourceOverride[], + ctx: AnnotationsContext ) { - const ev = this.evaluate.bind(this); + const ev = (x: TypedTemplateExpression) => this.evaluate(x, ctx); overrides.forEach((override: ResourceOverride) => { applyOverride(resource, override, ev); }); diff --git a/src/evaluate/references.ts b/src/evaluate/references.ts index e7c1c5c7..933ef579 100644 --- a/src/evaluate/references.ts +++ b/src/evaluate/references.ts @@ -1,7 +1,7 @@ import * as cdk from 'aws-cdk-lib'; import { Stack, Stage } from 'aws-cdk-lib'; import { Construct } from 'constructs'; -import { assertObject, ParserError } from '../parser/private/types'; +import { assertObject } from '../parser/private/types'; function isPropertyOf( instance: Record, @@ -21,7 +21,7 @@ export function getPropDot(instance: unknown, path: string): unknown { if (Array.isArray(o)) { const index = parseInt(p); if (isNaN(index) || !(0 <= index && index < o.length)) { - throw new ParserError( + throw new SyntaxError( `Expected an integer between 0 and ${o.length - 1}, got ${index}` ); } @@ -30,7 +30,7 @@ export function getPropDot(instance: unknown, path: string): unknown { const obj = assertObject(o); if (!isPropertyOf(obj, p)) { - throw new ParserError(`Expected Construct property path, got: ${path}`); + throw new SyntaxError(`Expected Construct property path, got: ${path}`); } return obj[p]; }, instance); diff --git a/src/parser/private/types.ts b/src/parser/private/types.ts index e634cb01..d7883999 100644 --- a/src/parser/private/types.ts +++ b/src/parser/private/types.ts @@ -1,16 +1,9 @@ import { RetentionPolicy } from '../template'; -export class ParserError extends Error { - constructor(msg: string) { - super(msg); - Object.setPrototypeOf(this, ParserError.prototype); - } -} - export function parseNumber(asString: string | number) { const asNumber = parseInt(`${asString}`, 10); if (`${asNumber}` !== `${asString}`) { - throw new ParserError(`Not a number: ${asString}`); + throw new SyntaxError(`Not a number: ${asString}`); } return { asString: `${asNumber}`, asNumber }; } @@ -22,7 +15,7 @@ export function assertString(x: unknown): string { if (typeof x === 'string') { return x; } - throw new ParserError(`Expected string, got: ${JSON.stringify(x)}`); + throw new SyntaxError(`Expected string, got: ${JSON.stringify(x)}`); } export function assertNumber(x: unknown): number { @@ -32,15 +25,15 @@ export function assertNumber(x: unknown): number { if (typeof x === 'string') { return parseNumber(x).asNumber; } - throw new ParserError(`Expected number, got: ${JSON.stringify(x)}`); + throw new SyntaxError(`Expected number, got: ${JSON.stringify(x)}`); } export function assertList(x: unknown, lengths?: number[]): T[] { if (!Array.isArray(x)) { - throw new ParserError(`Expected list, got: ${JSON.stringify(x)}`); + throw new SyntaxError(`Expected list, got: ${JSON.stringify(x)}`); } if (lengths && !lengths.includes(x.length)) { - throw new ParserError( + throw new SyntaxError( `Expected list of length ${lengths}, got ${x.length}` ); } @@ -56,7 +49,7 @@ export function assertListOfForm( return assertList(x).map(assertForm); } catch (e) { if (form) { - throw new ParserError( + throw new SyntaxError( `Expected list of form ${form}, got: ${JSON.stringify(x)}` ); } @@ -76,21 +69,21 @@ export function assertBoolean(x: unknown): boolean { case 'false': return false; default: - throw new ParserError(`Expected boolean, got: ${JSON.stringify(x)}`); + throw new SyntaxError(`Expected boolean, got: ${JSON.stringify(x)}`); } } export function assertTrue(x: unknown): true { assertBoolean(x); if (x !== true) { - throw new ParserError(`Expected 'true', got: ${JSON.stringify(x)}`); + throw new SyntaxError(`Expected 'true', got: ${JSON.stringify(x)}`); } return x; } export function assertObject(x: unknown): Record { if (typeof x !== 'object' || x == null || Array.isArray(x)) { - throw new ParserError(`Expected object, got: ${JSON.stringify(x)}`); + throw new SyntaxError(`Expected object, got: ${JSON.stringify(x)}`); } return x as any; } @@ -100,13 +93,13 @@ export function assertField( fieldName: K ): unknown { if (!(fieldName in xs)) { - throw new ParserError(`Expected field named '${String(fieldName)}'`); + throw new SyntaxError(`Expected field named '${String(fieldName)}'`); } return xs[fieldName]; } export function assertOneOf(x: A, allowed: unknown[]): A { if (!allowed.includes(x)) { - throw new ParserError( + throw new SyntaxError( `Expected one of ${allowed .map((f) => `'${String(f)}'`) .join('|')}, got: ${JSON.stringify(x)}` @@ -121,7 +114,7 @@ export function assertExactlyOneOfFields( ): K { const foundFields = fieldNames.filter((f) => f in xs); if (foundFields.length !== 1) { - throw new ParserError( + throw new SyntaxError( `Expected exactly one of the fields ${fieldNames .map((f) => `'${String(f)}'`) .join(', ')}, got: ${JSON.stringify(xs)}` @@ -136,7 +129,7 @@ export function assertAtMostOneOfFields( ): K | undefined { const foundFields = fieldNames.filter((f) => f in xs); if (foundFields.length > 1) { - throw new ParserError( + throw new SyntaxError( `Expected at most one of the fields ${fieldNames .map((f) => `'${String(f)}'`) .join(', ')}, got: ${JSON.stringify(xs)}` @@ -148,7 +141,7 @@ export function assertAtMostOneOfFields( export function assertOneField(xs: unknown): string { const fields = Object.keys(assertObject(xs)); if (fields.length !== 1) { - throw new ParserError( + throw new SyntaxError( `Expected exactly one field, got: ${JSON.stringify(xs)}` ); } @@ -162,13 +155,13 @@ export function assertStringOrListIntoList(x: unknown): string[] { if (Array.isArray(x)) { const nonStrings = x.filter((y) => typeof y !== 'string'); if (nonStrings.length > 0) { - throw new ParserError( + throw new SyntaxError( `Expected all strings in array, found: ${nonStrings}` ); } return x as string[]; } - throw new ParserError( + throw new SyntaxError( `Expected string or list of strings, got: ${JSON.stringify(x)}` ); } @@ -187,7 +180,7 @@ export function parseRetentionPolicy(x: unknown): RetentionPolicy { return 'Snapshot'; } - throw new ParserError( + throw new SyntaxError( `Expected one of Delete|Retain|Snapshot, got: ${JSON.stringify(x)}` ); } @@ -212,7 +205,7 @@ export function assertOr( } catch {} } - throw new ParserError(error(JSON.stringify(x))); + throw new SyntaxError(error(JSON.stringify(x))); } export function assertStringOrStringList(xs: unknown): string | string[] { diff --git a/src/parser/template/calls.ts b/src/parser/template/calls.ts index bb7a4b34..69ab81f7 100644 --- a/src/parser/template/calls.ts +++ b/src/parser/template/calls.ts @@ -1,4 +1,4 @@ -import { assertOneField, assertString, ParserError } from '../private/types'; +import { assertOneField, assertString } from '../private/types'; import { ArrayLiteral, parseExpression, @@ -21,7 +21,7 @@ export function parseCall(x: unknown): FactoryMethodCall | undefined { case 2: return parseInstanceCall(array); default: - throw new ParserError( + throw new SyntaxError( `Method calls should have 1 or 2 elements, got ${array}` ); } diff --git a/src/parser/template/expression.ts b/src/parser/template/expression.ts index eb972e8b..8a9ef904 100644 --- a/src/parser/template/expression.ts +++ b/src/parser/template/expression.ts @@ -4,7 +4,6 @@ import { assertList, assertObject, assertString, - ParserError, } from '../private/types'; export type TemplateExpression = @@ -48,7 +47,9 @@ export interface ArrayExpression { export interface ArrayLiteral extends ArrayExpression {} export interface ObjectLiteral extends ObjectExpression {} -export type IntrinsicExpression = +export type IntrinsicExpression = UserIntrinsicExpression | LazyLogicalId; + +export type UserIntrinsicExpression = | RefIntrinsic | GetAttIntrinsic | GetPropIntrinsic @@ -68,7 +69,6 @@ export type IntrinsicExpression = | NotIntrinsic | EqualsIntrinsic | ArgsIntrinsic - | LazyLogicalId | LengthIntrinsic | ToJsonStringIntrinsic; @@ -219,6 +219,33 @@ export interface ToJsonStringIntrinsic { readonly value: Record; } +export const INTRINSIC_NAME_MAP: Record< + Exclude, + string +> = { + ref: 'Ref', + getAtt: 'Fn::GetAtt', + getProp: 'CDK::GetProp', + base64: 'Fn::Base64', + cidr: 'Fn::Cidr', + findInMap: 'Fn::FindInMap', + getAzs: 'Fn::GetAZs', + if: 'Fn::If', + importValue: 'Fn::ImportValue', + join: 'Fn::Join', + select: 'Fn::Select', + split: 'Fn::Split', + sub: 'Fn::Sub', + transform: 'Fn::Transform', + and: 'Fn::And', + or: 'Fn::Or', + not: 'Fn::Not', + equals: 'Fn::Equals', + args: 'CDK::Args', + length: 'Fn::Length', + toJsonString: 'Fn::ToJsonString', +}; + export function isExpression(x: unknown): x is TemplateExpression { try { assertExpression(x); @@ -239,7 +266,7 @@ export function assertExpression(x: unknown): TemplateExpression { 'intrinsic', ]; if (!expressionTypes.includes(expressionType)) { - throw new ParserError( + throw new SyntaxError( `Expected ${expressionTypes.join('|')}, got: '${JSON.stringify( expressionType )}'` @@ -249,25 +276,6 @@ export function assertExpression(x: unknown): TemplateExpression { return x as TemplateExpression; } -export function unparseExpression(x: TemplateExpression): any { - switch (x.type) { - case 'string': - case 'number': - case 'boolean': - return x.value; - case 'null': - return null; - case 'array': - return x.array.map(unparseExpression); - case 'object': - return Object.fromEntries( - Object.entries(x.fields).map(([k, v]) => [k, unparseExpression(v)]) - ); - default: - return x; - } -} - export function parseExpression(x: unknown): TemplateExpression { if (x == null) { return { type: 'null' }; @@ -287,12 +295,12 @@ export function parseExpression(x: unknown): TemplateExpression { } const INTRINSIC_TABLE: Record IntrinsicExpression> = { - Ref: (value) => ({ + [INTRINSIC_NAME_MAP.ref]: (value) => ({ type: 'intrinsic', fn: 'ref', logicalId: assertString(value), }), - 'Fn::GetAtt': (value) => { + [INTRINSIC_NAME_MAP.getAtt]: (value) => { if (typeof value == 'string') { const [id, ...attrs] = value.split('.'); value = [id, attrs.join('.')]; @@ -305,7 +313,7 @@ export function parseExpression(x: unknown): TemplateExpression { attribute: parseExpression(xs[1]), }; }, - 'CDK::GetProp': (value) => { + [INTRINSIC_NAME_MAP.getProp]: (value) => { if (typeof value == 'string') { const [id, ...attrs] = value.split('.'); value = [id, attrs.join('.')]; @@ -318,12 +326,12 @@ export function parseExpression(x: unknown): TemplateExpression { property: assertString(xs[1]), }; }, - 'Fn::Base64': (value) => ({ + [INTRINSIC_NAME_MAP.base64]: (value) => ({ type: 'intrinsic', fn: 'base64', expression: parseExpression(value), }), - 'Fn::Cidr': (value) => { + [INTRINSIC_NAME_MAP.cidr]: (value) => { const xs = assertList(value, [2, 3]); return { type: 'intrinsic', @@ -333,7 +341,7 @@ export function parseExpression(x: unknown): TemplateExpression { netMask: xs[2] ? parseExpression(xs[2]) : undefined, }; }, - 'Fn::FindInMap': (value) => { + [INTRINSIC_NAME_MAP.findInMap]: (value) => { const xs = assertList(value, [3]); return { type: 'intrinsic', @@ -343,12 +351,12 @@ export function parseExpression(x: unknown): TemplateExpression { key2: parseExpression(xs[2]), }; }, - 'Fn::GetAZs': (value) => ({ + [INTRINSIC_NAME_MAP.getAzs]: (value) => ({ type: 'intrinsic', fn: 'getAzs', region: parseExpression(value), }), - 'Fn::If': (value) => { + [INTRINSIC_NAME_MAP.if]: (value) => { const xs = assertList(value, [3]); return { type: 'intrinsic', @@ -358,12 +366,12 @@ export function parseExpression(x: unknown): TemplateExpression { else: parseExpression(xs[2]), }; }, - 'Fn::ImportValue': (value) => ({ + [INTRINSIC_NAME_MAP.importValue]: (value) => ({ type: 'intrinsic', fn: 'importValue', export: parseExpression(value), }), - 'Fn::Join': (value) => { + [INTRINSIC_NAME_MAP.join]: (value) => { const xs = assertList(value, [2]); return { type: 'intrinsic', @@ -372,7 +380,7 @@ export function parseExpression(x: unknown): TemplateExpression { list: parseListIntrinsic(xs[1]), }; }, - 'Fn::Select': (value) => { + [INTRINSIC_NAME_MAP.select]: (value) => { const xs = assertList(value, [2]); return { type: 'intrinsic', @@ -381,7 +389,7 @@ export function parseExpression(x: unknown): TemplateExpression { objects: parseListIntrinsic(xs[1]), }; }, - 'Fn::Split': (value) => { + [INTRINSIC_NAME_MAP.split]: (value) => { const xs = assertList(value, [2]); return { type: 'intrinsic', @@ -390,7 +398,7 @@ export function parseExpression(x: unknown): TemplateExpression { value: parseExpression(xs[1]), }; }, - 'Fn::Sub': (value) => { + [INTRINSIC_NAME_MAP.sub]: (value) => { let pattern: string; let context: Record; if (typeof value === 'string') { @@ -412,7 +420,7 @@ export function parseExpression(x: unknown): TemplateExpression { additionalContext: context, }; }, - 'Fn::Transform': (value) => { + [INTRINSIC_NAME_MAP.transform]: (value) => { const fields = assertObject(value); const parameters = parseObject(assertField(fields, 'Parameters')); @@ -423,28 +431,28 @@ export function parseExpression(x: unknown): TemplateExpression { parameters, }; }, - 'Fn::And': (value) => { + [INTRINSIC_NAME_MAP.and]: (value) => { return { type: 'intrinsic', fn: 'and', operands: assertList(value).map(parseExpression), }; }, - 'Fn::Or': (value) => { + [INTRINSIC_NAME_MAP.or]: (value) => { return { type: 'intrinsic', fn: 'or', operands: assertList(value).map(parseExpression), }; }, - 'Fn::Not': (value) => { + [INTRINSIC_NAME_MAP.not]: (value) => { return { type: 'intrinsic', fn: 'not', operand: parseExpression(value), }; }, - 'Fn::Equals': (value) => { + [INTRINSIC_NAME_MAP.equals]: (value) => { const [x1, x2] = assertList(value, [2]); return { type: 'intrinsic', @@ -453,21 +461,21 @@ export function parseExpression(x: unknown): TemplateExpression { value2: parseExpression(x2), }; }, - 'CDK::Args': (value) => { + [INTRINSIC_NAME_MAP.args]: (value) => { return { type: 'intrinsic', fn: 'args', array: assertList(value).map(parseExpression), }; }, - 'Fn::Length': (value) => { + [INTRINSIC_NAME_MAP.length]: (value) => { return { type: 'intrinsic', fn: 'length', list: parseListIntrinsic(value), }; }, - 'Fn::ToJsonString': (value) => { + [INTRINSIC_NAME_MAP.toJsonString]: (value) => { return { type: 'intrinsic', fn: 'toJsonString', @@ -547,7 +555,7 @@ export function assertIntrinsic( } } - throw new ParserError( + throw new SyntaxError( `Expected one of Intrinsic Functions [${fns.join( '|' )}], got: ${JSON.stringify(x)}` diff --git a/src/parser/template/overrides.ts b/src/parser/template/overrides.ts index e892d2e1..bf7f51a0 100644 --- a/src/parser/template/overrides.ts +++ b/src/parser/template/overrides.ts @@ -5,7 +5,6 @@ import { assertObject, assertString, assertTrue, - ParserError, } from '../private/types'; import { ifField, parseExpression, TemplateExpression } from './expression'; @@ -53,7 +52,7 @@ function parseOverride(x: unknown): ResourceOverride { case 'RemoveResource': return parseRemoveResource(override); default: - throw new ParserError('Unexpected Error'); + throw new SyntaxError('Unexpected Error'); } } diff --git a/src/type-resolution/expression.ts b/src/type-resolution/expression.ts index 0f7f73a4..aa4b3fe3 100644 --- a/src/type-resolution/expression.ts +++ b/src/type-resolution/expression.ts @@ -1,4 +1,5 @@ import * as reflect from 'jsii-reflect'; +import { unparseExpression } from '../error-handling/unparse'; import { ArrayExpression, BooleanLiteral, @@ -8,7 +9,6 @@ import { ObjectExpression, StringLiteral, TemplateExpression, - unparseExpression, } from '../parser/template'; import { InitializerExpression, diff --git a/src/type-resolution/primitives.ts b/src/type-resolution/primitives.ts index ef1b4d4f..ed4abfd0 100644 --- a/src/type-resolution/primitives.ts +++ b/src/type-resolution/primitives.ts @@ -1,5 +1,4 @@ import * as reflect from 'jsii-reflect'; -import { ParserError } from '../parser/private/types'; import { IntrinsicExpression, TemplateExpression } from '../parser/template'; export interface AnyTemplateExpression { @@ -28,7 +27,7 @@ export interface VoidExpression { export function assertVoid(x: unknown): void { if (x !== undefined || x !== null) { - throw new ParserError(`Expected nothing, got: ${JSON.stringify(x)}`); + throw new SyntaxError(`Expected nothing, got: ${JSON.stringify(x)}`); } } @@ -45,7 +44,7 @@ export function assertLiteralOrIntrinsic< type: T ): (TemplateExpression & { type: T }) | IntrinsicExpression { if (![type, 'intrinsic'].includes(x.type)) { - throw new ParserError(`Expected ${type}, got: ${JSON.stringify(x)}`); + throw new SyntaxError(`Expected ${type}, got: ${JSON.stringify(x)}`); } return x as TemplateExpression & { type: T }; diff --git a/src/type-resolution/references.ts b/src/type-resolution/references.ts index 5770a052..1be150eb 100644 --- a/src/type-resolution/references.ts +++ b/src/type-resolution/references.ts @@ -1,4 +1,3 @@ -import { ParserError } from '../parser/private/types'; import { GetPropIntrinsic, RefIntrinsic, @@ -20,7 +19,7 @@ export function resolveRefToValue(x: RefIntrinsic): ResolveReferenceExpression { export function assertRef(x: TemplateExpression): RefIntrinsic { if (x.type !== 'intrinsic' || x.fn !== 'ref') { - throw new ParserError(`Expected Ref, got: ${JSON.stringify(x)}`); + throw new SyntaxError(`Expected Ref, got: ${JSON.stringify(x)}`); } return x; diff --git a/src/type-resolution/union.ts b/src/type-resolution/union.ts index 5b629840..6731d5ec 100644 --- a/src/type-resolution/union.ts +++ b/src/type-resolution/union.ts @@ -1,5 +1,4 @@ import * as reflect from 'jsii-reflect'; -import { ParserError } from '../parser/private/types'; import { TemplateExpression } from '../parser/template/expression'; import { TypedTemplateExpression } from './expression'; import { resolveExpressionType } from './resolve'; @@ -8,12 +7,12 @@ export function resolveUnionOfTypesExpression( x: TemplateExpression, unionTypeRefs: reflect.TypeReference[] ): TypedTemplateExpression { - const errors = new Array(); + const errors = new Array(); for (const typeRef of unionTypeRefs) { try { return resolveExpressionType(x, typeRef); } catch (e) { - if (!(e instanceof TypeError || e instanceof ParserError)) { + if (!(e instanceof TypeError || e instanceof SyntaxError)) { throw e; } errors.push(e); diff --git a/test/evaluate/errors.test.ts b/test/evaluate/errors.test.ts index de724bf9..016c7a1f 100644 --- a/test/evaluate/errors.test.ts +++ b/test/evaluate/errors.test.ts @@ -1,4 +1,5 @@ import { expect } from 'expect'; +import { DeclarativeStackError } from '../../src/error-handling'; import { Template } from '../../src/parser/template'; import { Testing } from '../util'; @@ -94,4 +95,56 @@ suite('Evaluation errors', () => { ); }); }); + + suite('Multiple errors', () => { + test('Evaluation errors are collected', async () => { + // GIVEN + const template = { + Resources: { + SiteDistribution: { + Type: 'aws-cdk-lib.aws_cloudfront.Distribution', + Properties: { + certificate: { + Ref: 'SiteBucket', // this should be SiteCertificate + }, + defaultRootObject: 'index.html', + domainNames: [ + { + Ref: 'DomainName', + }, + ], + minimumProtocolVersion: 'TLS_V1_2_2021', + defaultBehavior: { + origin: { + 'aws-cdk-lib.aws_cloudfront_origins.S3Origin': [ + { Ref: 'SiteCertificate' }, // this should be SiteBucket + { + originAccessIdentity: { + Ref: 'CloudFrontOAI', + }, + }, + ], + }, + }, + }, + }, + }, + }; + const synth = Testing.synth(await Template.fromObject(template), { + validateTemplate: false, + }); + + // THEN + await expect(synth).rejects.toThrow(DeclarativeStackError); + try { + await synth; + } catch (error) { + const msg = error.toString(); + expect(msg).toContain('No such Resource or Parameter: SiteCertificate'); + expect(msg).toContain('No such Resource or Parameter: CloudFrontOAI'); + expect(msg).toContain('No such Resource or Parameter: SiteBucket'); + expect(msg).toContain('No such Resource or Parameter: DomainName'); + } + }); + }); });