Skip to content

Commit 8362efe

Browse files
authored
feat(integ-tests): make assertions on deployed infrastructure (#20071)
This PR introduces a new group of constructs that allow you to make assertions against deployed infrastructure. They are not exported yet so we can work through the todo list in follow up PRs. TODO: - [ ] Add more assertion types (i.e. objectContaining) - [ ] Update integ-runner to collect the assertion results - [ ] Assertion custom resources should not(?) be part of the snapshot diff - [ ] Assertions need to be run on every deploy (i.e. update workflow) but that should not be part of the snapshot diff ---- ### All Submissions: * [ ] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/master/CONTRIBUTING.md) ### Adding new Unconventional Dependencies: * [ ] This PR adds new unconventional dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/master/CONTRIBUTING.md/#adding-new-unconventional-dependencies) ### New Features * [ ] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/master/INTEGRATION_TESTS.md)? * [ ] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)? *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent fd306ee commit 8362efe

25 files changed

+2718
-7
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { CustomResource } from '@aws-cdk/core';
2+
import { Construct } from 'constructs';
3+
import { IAssertion } from './deploy-assert';
4+
import { AssertionRequest, AssertionsProvider, ASSERT_RESOURCE_TYPE, AssertionType } from './providers';
5+
//
6+
// keep this import separate from other imports to reduce chance for merge conflicts with v2-main
7+
// eslint-disable-next-line no-duplicate-imports, import/order
8+
import { Construct as CoreConstruct } from '@aws-cdk/core';
9+
10+
/**
11+
* Options for an EqualsAssertion
12+
*/
13+
export interface EqualsAssertionProps {
14+
/**
15+
* The CustomResource that continains the "actual" results
16+
*/
17+
readonly inputResource: CustomResource;
18+
19+
/**
20+
* The CustomResource attribute that continains the "actual" results
21+
*/
22+
readonly inputResourceAtt: string;
23+
24+
/**
25+
* The expected result to assert
26+
*/
27+
readonly expected: any;
28+
}
29+
30+
/**
31+
* Construct that creates a CustomResource to assert that two
32+
* values are equal
33+
*/
34+
export class EqualsAssertion extends CoreConstruct implements IAssertion {
35+
public readonly result: string;
36+
37+
constructor(scope: Construct, id: string, props: EqualsAssertionProps) {
38+
super(scope, id);
39+
40+
const assertionProvider = new AssertionsProvider(this, 'AssertionProvider');
41+
const properties: AssertionRequest = {
42+
actual: props.inputResource.getAttString(props.inputResourceAtt),
43+
expected: props.expected,
44+
assertionType: AssertionType.EQUALS,
45+
};
46+
const resource = new CustomResource(this, 'Default', {
47+
serviceToken: assertionProvider.serviceToken,
48+
properties,
49+
resourceType: ASSERT_RESOURCE_TYPE,
50+
});
51+
this.result = resource.getAttString('data');
52+
}
53+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { CfnOutput, CustomResource, Lazy } from '@aws-cdk/core';
2+
import { Construct, IConstruct, Node } from 'constructs';
3+
import { md5hash } from './private/hash';
4+
import { RESULTS_RESOURCE_TYPE, AssertionsProvider } from './providers';
5+
import { SdkQuery, SdkQueryOptions } from './sdk';
6+
7+
const DEPLOY_ASSERT_SYMBOL = Symbol.for('@aws-cdk/integ-tests.DeployAssert');
8+
9+
// keep this import separate from other imports to reduce chance for merge conflicts with v2-main
10+
// eslint-disable-next-line no-duplicate-imports, import/order
11+
import { Construct as CoreConstruct } from '@aws-cdk/core';
12+
13+
/**
14+
* Represents a deploy time assertion
15+
*/
16+
export interface IAssertion {
17+
/**
18+
* The result of the assertion
19+
*/
20+
readonly result: string;
21+
}
22+
23+
/**
24+
* Options for DeployAssert
25+
*/
26+
export interface DeployAssertProps { }
27+
28+
/**
29+
* Construct that allows for registering a list of assertions
30+
* that should be performed on a construct
31+
*/
32+
export class DeployAssert extends CoreConstruct {
33+
34+
/**
35+
* Returns whether the construct is a DeployAssert construct
36+
*/
37+
public static isDeployAssert(x: any): x is DeployAssert {
38+
return x !== null && typeof(x) === 'object' && DEPLOY_ASSERT_SYMBOL in x;
39+
}
40+
41+
/**
42+
* Finds a DeployAssert construct in the given scope
43+
*/
44+
public static of(construct: IConstruct): DeployAssert {
45+
const scopes = Node.of(construct).scopes.reverse();
46+
const deployAssert = scopes.find(s => DeployAssert.isDeployAssert(s));
47+
if (!deployAssert) {
48+
throw new Error('No DeployAssert construct found in scopes');
49+
}
50+
return deployAssert as DeployAssert;
51+
}
52+
53+
/** @internal */
54+
public readonly _assertions: IAssertion[];
55+
56+
constructor(scope: Construct) {
57+
super(scope, 'DeployAssert');
58+
59+
Object.defineProperty(this, DEPLOY_ASSERT_SYMBOL, { value: true });
60+
this._assertions = [];
61+
62+
const provider = new AssertionsProvider(this, 'ResultsProvider');
63+
64+
const resource = new CustomResource(this, 'ResultsCollection', {
65+
serviceToken: provider.serviceToken,
66+
properties: {
67+
assertionResults: Lazy.list({
68+
produce: () => this._assertions.map(a => a.result),
69+
}),
70+
},
71+
resourceType: RESULTS_RESOURCE_TYPE,
72+
});
73+
74+
// TODO: need to show/store this information
75+
new CfnOutput(this, 'Results', {
76+
value: `\n${resource.getAttString('message')}`,
77+
}).overrideLogicalId('Results');
78+
}
79+
80+
/**
81+
* Query AWS using JavaScript SDK V2 API calls
82+
*/
83+
public queryAws(options: SdkQueryOptions): SdkQuery {
84+
const id = md5hash(options);
85+
return new SdkQuery(this, `SdkQuery${id}`, options);
86+
}
87+
88+
/**
89+
* Register an assertion that should be run as part of the
90+
* deployment
91+
*/
92+
public registerAssertion(assertion: IAssertion) {
93+
this._assertions.push(assertion);
94+
}
95+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './assertions';
2+
export * from './sdk';
3+
export * from './deploy-assert';
4+
export * from './providers';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import * as crypto from 'crypto';
2+
3+
export function md5hash(obj: any): string {
4+
if (!obj || (typeof(obj) === 'object' && Object.keys(obj).length === 0)) {
5+
throw new Error('Cannot compute md5 hash for falsy object');
6+
}
7+
const hash = crypto.createHash('md5');
8+
hash.update(JSON.stringify(obj));
9+
return hash.digest('hex');
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './lambda-handler/types';
2+
export * from './provider';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/* eslint-disable no-console */
2+
import * as assert from 'assert';
3+
import { CustomResourceHandler } from './base';
4+
import { AssertionRequest, AssertionResult } from './types';
5+
6+
export class AssertionHandler extends CustomResourceHandler<AssertionRequest, AssertionResult> {
7+
protected async processEvent(request: AssertionRequest): Promise<AssertionResult | undefined> {
8+
let result: AssertionResult;
9+
switch (request.assertionType) {
10+
case 'equals':
11+
console.log(`Testing equality between ${JSON.stringify(request.actual)} and ${JSON.stringify(request.expected)}`);
12+
try {
13+
assert.deepStrictEqual(request.actual, request.expected);
14+
result = { data: { status: 'pass' } };
15+
} catch (e) {
16+
if (e instanceof assert.AssertionError) {
17+
result = {
18+
data: {
19+
status: 'fail',
20+
message: e.message,
21+
},
22+
};
23+
} else {
24+
throw e;
25+
}
26+
}
27+
break;
28+
default:
29+
throw new Error(`Unsupported query type ${request.assertionType}`);
30+
}
31+
32+
return result;
33+
}
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/* eslint-disable no-console */
2+
import * as https from 'https';
3+
import * as url from 'url';
4+
5+
interface HandlerResponse {
6+
readonly status: 'SUCCESS' | 'FAILED';
7+
readonly reason: 'OK' | string;
8+
readonly data?: any;
9+
}
10+
11+
// eslint-disable-next-line @typescript-eslint/ban-types
12+
export abstract class CustomResourceHandler<Request extends object, Response extends object> {
13+
public readonly physicalResourceId: string;
14+
private readonly timeout: NodeJS.Timeout;
15+
private timedOut = false;
16+
17+
constructor(protected readonly event: AWSLambda.CloudFormationCustomResourceEvent, protected readonly context: AWSLambda.Context) {
18+
this.timeout = setTimeout(async () => {
19+
await this.respond({
20+
status: 'FAILED',
21+
reason: 'Lambda Function Timeout',
22+
data: this.context.logStreamName,
23+
});
24+
this.timedOut = true;
25+
}, context.getRemainingTimeInMillis() - 1200);
26+
this.event = event;
27+
this.physicalResourceId = extractPhysicalResourceId(event);
28+
}
29+
30+
public async handle(): Promise<void> {
31+
try {
32+
console.log(`Event: ${JSON.stringify(this.event)}`);
33+
const response = await this.processEvent(this.event.ResourceProperties as unknown as Request);
34+
console.log(`Event output : ${JSON.stringify(response)}`);
35+
await this.respond({
36+
status: 'SUCCESS',
37+
reason: 'OK',
38+
data: response,
39+
});
40+
} catch (e) {
41+
console.log(e);
42+
await this.respond({
43+
status: 'FAILED',
44+
reason: e.message ?? 'Internal Error',
45+
});
46+
} finally {
47+
clearTimeout(this.timeout);
48+
}
49+
}
50+
51+
protected abstract processEvent(request: Request): Promise<Response | undefined>;
52+
53+
private respond(response: HandlerResponse) {
54+
if (this.timedOut) {
55+
return;
56+
}
57+
const cfResponse: AWSLambda.CloudFormationCustomResourceResponse = {
58+
Status: response.status,
59+
Reason: response.reason,
60+
PhysicalResourceId: this.physicalResourceId,
61+
StackId: this.event.StackId,
62+
RequestId: this.event.RequestId,
63+
LogicalResourceId: this.event.LogicalResourceId,
64+
NoEcho: false,
65+
Data: response.data,
66+
};
67+
const responseBody = JSON.stringify(cfResponse);
68+
69+
console.log('Responding to CloudFormation', responseBody);
70+
71+
const parsedUrl = url.parse(this.event.ResponseURL);
72+
const requestOptions = {
73+
hostname: parsedUrl.hostname,
74+
path: parsedUrl.path,
75+
method: 'PUT',
76+
headers: { 'content-type': '', 'content-length': responseBody.length },
77+
};
78+
79+
return new Promise((resolve, reject) => {
80+
try {
81+
const request = https.request(requestOptions, resolve);
82+
request.on('error', reject);
83+
request.write(responseBody);
84+
request.end();
85+
} catch (e) {
86+
reject(e);
87+
}
88+
});
89+
}
90+
}
91+
92+
function extractPhysicalResourceId(event: AWSLambda.CloudFormationCustomResourceEvent): string {
93+
switch (event.RequestType) {
94+
case 'Create':
95+
return event.LogicalResourceId;
96+
case 'Update':
97+
case 'Delete':
98+
return event.PhysicalResourceId;
99+
}
100+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { AssertionHandler } from './assertion';
2+
import { ResultsCollectionHandler } from './results';
3+
import { SdkHandler } from './sdk';
4+
import * as types from './types';
5+
6+
export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent, context: AWSLambda.Context) {
7+
const provider = createResourceHandler(event, context);
8+
await provider.handle();
9+
}
10+
11+
function createResourceHandler(event: AWSLambda.CloudFormationCustomResourceEvent, context: AWSLambda.Context) {
12+
if (event.ResourceType.startsWith(types.SDK_RESOURCE_TYPE_PREFIX)) {
13+
return new SdkHandler(event, context);
14+
}
15+
switch (event.ResourceType) {
16+
case types.ASSERT_RESOURCE_TYPE: return new AssertionHandler(event, context);
17+
case types.RESULTS_RESOURCE_TYPE: return new ResultsCollectionHandler(event, context);
18+
default:
19+
throw new Error(`Unsupported resource type "${event.ResourceType}`);
20+
}
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { CustomResourceHandler } from './base';
2+
import { ResultsCollectionRequest, ResultsCollectionResult } from './types';
3+
4+
export class ResultsCollectionHandler extends CustomResourceHandler<ResultsCollectionRequest, ResultsCollectionResult> {
5+
protected async processEvent(request: ResultsCollectionRequest): Promise<ResultsCollectionResult | undefined> {
6+
const reduced: string = request.assertionResults.reduce((agg, result, idx) => {
7+
const msg = result.status === 'pass' ? 'pass' : `fail - ${result.message}`;
8+
return `${agg}\nTest${idx}: ${msg}`;
9+
}, '').trim();
10+
return { message: reduced };
11+
}
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/* eslint-disable no-console */
2+
import { CustomResourceHandler } from './base';
3+
import { SdkRequest, SdkResult } from './types';
4+
5+
/**
6+
* Flattens a nested object
7+
*
8+
* @param object the object to be flattened
9+
* @returns a flat object with path as keys
10+
*/
11+
export function flatten(object: object): { [key: string]: any } {
12+
return Object.assign(
13+
{},
14+
...function _flatten(child: any, path: string[] = []): any {
15+
return [].concat(...Object.keys(child)
16+
.map(key => {
17+
const childKey = Buffer.isBuffer(child[key]) ? child[key].toString('utf8') : child[key];
18+
return typeof childKey === 'object' && childKey !== null
19+
? _flatten(childKey, path.concat([key]))
20+
: ({ [path.concat([key]).join('.')]: childKey });
21+
}));
22+
}(object),
23+
);
24+
}
25+
26+
27+
export class SdkHandler extends CustomResourceHandler<SdkRequest, SdkResult | { [key: string]: string }> {
28+
protected async processEvent(request: SdkRequest): Promise<SdkResult | { [key: string]: string } | undefined> {
29+
// eslint-disable-next-line
30+
const AWS: any = require('aws-sdk');
31+
console.log(`AWS SDK VERSION: ${AWS.VERSION}`);
32+
33+
const service = new AWS[request.service]();
34+
const response = await service[request.api](request.parameters && decode(request.parameters)).promise();
35+
console.log(`SDK response received ${JSON.stringify(response)}`);
36+
delete response.ResponseMetadata;
37+
const respond = {
38+
apiCallResponse: response,
39+
};
40+
const flatData: { [key: string]: string } = {
41+
...flatten(respond),
42+
};
43+
44+
return request.flattenResponse === 'true' ? flatData : respond;
45+
}
46+
}
47+
48+
function decode(object: Record<string, unknown>) {
49+
return JSON.parse(JSON.stringify(object), (_k, v) => {
50+
switch (v) {
51+
case 'TRUE:BOOLEAN':
52+
return true;
53+
case 'FALSE:BOOLEAN':
54+
return false;
55+
default:
56+
return v;
57+
}
58+
});
59+
}

0 commit comments

Comments
 (0)