Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(integ-tests): make assertions on deployed infrastructure #20071

Merged
merged 4 commits into from
Apr 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions packages/@aws-cdk/integ-tests/lib/assertions/assertions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { CustomResource } from '@aws-cdk/core';
import { Construct } from 'constructs';
import { IAssertion } from './deploy-assert';
import { AssertionRequest, AssertionsProvider, ASSERT_RESOURCE_TYPE, AssertionType } from './providers';
//
// 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
import { Construct as CoreConstruct } from '@aws-cdk/core';

/**
* Options for an EqualsAssertion
*/
export interface EqualsAssertionProps {
/**
* The CustomResource that continains the "actual" results
*/
readonly inputResource: CustomResource;

/**
* The CustomResource attribute that continains the "actual" results
*/
readonly inputResourceAtt: string;

/**
* The expected result to assert
*/
readonly expected: any;
}

/**
* Construct that creates a CustomResource to assert that two
* values are equal
*/
export class EqualsAssertion extends CoreConstruct implements IAssertion {
public readonly result: string;

constructor(scope: Construct, id: string, props: EqualsAssertionProps) {
super(scope, id);

const assertionProvider = new AssertionsProvider(this, 'AssertionProvider');
const properties: AssertionRequest = {
actual: props.inputResource.getAttString(props.inputResourceAtt),
expected: props.expected,
assertionType: AssertionType.EQUALS,
};
const resource = new CustomResource(this, 'Default', {
serviceToken: assertionProvider.serviceToken,
properties,
resourceType: ASSERT_RESOURCE_TYPE,
});
this.result = resource.getAttString('data');
}
}
95 changes: 95 additions & 0 deletions packages/@aws-cdk/integ-tests/lib/assertions/deploy-assert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { CfnOutput, CustomResource, Lazy } from '@aws-cdk/core';
import { Construct, IConstruct, Node } from 'constructs';
import { md5hash } from './private/hash';
import { RESULTS_RESOURCE_TYPE, AssertionsProvider } from './providers';
import { SdkQuery, SdkQueryOptions } from './sdk';

const DEPLOY_ASSERT_SYMBOL = Symbol.for('@aws-cdk/integ-tests.DeployAssert');

// 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
import { Construct as CoreConstruct } from '@aws-cdk/core';

/**
* Represents a deploy time assertion
*/
export interface IAssertion {
/**
* The result of the assertion
*/
readonly result: string;
}

/**
* Options for DeployAssert
*/
export interface DeployAssertProps { }

/**
* Construct that allows for registering a list of assertions
* that should be performed on a construct
*/
export class DeployAssert extends CoreConstruct {

/**
* Returns whether the construct is a DeployAssert construct
*/
public static isDeployAssert(x: any): x is DeployAssert {
return x !== null && typeof(x) === 'object' && DEPLOY_ASSERT_SYMBOL in x;
}

/**
* Finds a DeployAssert construct in the given scope
*/
public static of(construct: IConstruct): DeployAssert {
const scopes = Node.of(construct).scopes.reverse();
const deployAssert = scopes.find(s => DeployAssert.isDeployAssert(s));
if (!deployAssert) {
throw new Error('No DeployAssert construct found in scopes');
}
return deployAssert as DeployAssert;
}

/** @internal */
public readonly _assertions: IAssertion[];

constructor(scope: Construct) {
super(scope, 'DeployAssert');

Object.defineProperty(this, DEPLOY_ASSERT_SYMBOL, { value: true });
this._assertions = [];

const provider = new AssertionsProvider(this, 'ResultsProvider');

const resource = new CustomResource(this, 'ResultsCollection', {
serviceToken: provider.serviceToken,
properties: {
assertionResults: Lazy.list({
produce: () => this._assertions.map(a => a.result),
}),
},
resourceType: RESULTS_RESOURCE_TYPE,
});

// TODO: need to show/store this information
new CfnOutput(this, 'Results', {
value: `\n${resource.getAttString('message')}`,
}).overrideLogicalId('Results');
}

/**
* Query AWS using JavaScript SDK V2 API calls
*/
public queryAws(options: SdkQueryOptions): SdkQuery {
const id = md5hash(options);
return new SdkQuery(this, `SdkQuery${id}`, options);
}

/**
* Register an assertion that should be run as part of the
* deployment
*/
public registerAssertion(assertion: IAssertion) {
this._assertions.push(assertion);
}
}
4 changes: 4 additions & 0 deletions packages/@aws-cdk/integ-tests/lib/assertions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './assertions';
export * from './sdk';
export * from './deploy-assert';
export * from './providers';
10 changes: 10 additions & 0 deletions packages/@aws-cdk/integ-tests/lib/assertions/private/hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as crypto from 'crypto';

export function md5hash(obj: any): string {
if (!obj || (typeof(obj) === 'object' && Object.keys(obj).length === 0)) {
throw new Error('Cannot compute md5 hash for falsy object');
}
const hash = crypto.createHash('md5');
hash.update(JSON.stringify(obj));
return hash.digest('hex');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './lambda-handler/types';
export * from './provider';
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/* eslint-disable no-console */
import * as assert from 'assert';
import { CustomResourceHandler } from './base';
import { AssertionRequest, AssertionResult } from './types';

export class AssertionHandler extends CustomResourceHandler<AssertionRequest, AssertionResult> {
protected async processEvent(request: AssertionRequest): Promise<AssertionResult | undefined> {
let result: AssertionResult;
switch (request.assertionType) {
case 'equals':
console.log(`Testing equality between ${JSON.stringify(request.actual)} and ${JSON.stringify(request.expected)}`);
try {
assert.deepStrictEqual(request.actual, request.expected);
result = { data: { status: 'pass' } };
} catch (e) {
if (e instanceof assert.AssertionError) {
result = {
data: {
status: 'fail',
message: e.message,
},
};
} else {
throw e;
}
}
break;
default:
throw new Error(`Unsupported query type ${request.assertionType}`);
}

return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/* eslint-disable no-console */
import * as https from 'https';
import * as url from 'url';

interface HandlerResponse {
readonly status: 'SUCCESS' | 'FAILED';
readonly reason: 'OK' | string;
readonly data?: any;
}

// eslint-disable-next-line @typescript-eslint/ban-types
export abstract class CustomResourceHandler<Request extends object, Response extends object> {
public readonly physicalResourceId: string;
private readonly timeout: NodeJS.Timeout;
private timedOut = false;

constructor(protected readonly event: AWSLambda.CloudFormationCustomResourceEvent, protected readonly context: AWSLambda.Context) {
this.timeout = setTimeout(async () => {
await this.respond({
status: 'FAILED',
reason: 'Lambda Function Timeout',
data: this.context.logStreamName,
});
this.timedOut = true;
}, context.getRemainingTimeInMillis() - 1200);
this.event = event;
this.physicalResourceId = extractPhysicalResourceId(event);
}

public async handle(): Promise<void> {
try {
console.log(`Event: ${JSON.stringify(this.event)}`);
const response = await this.processEvent(this.event.ResourceProperties as unknown as Request);
console.log(`Event output : ${JSON.stringify(response)}`);
await this.respond({
status: 'SUCCESS',
reason: 'OK',
data: response,
});
} catch (e) {
console.log(e);
await this.respond({
status: 'FAILED',
reason: e.message ?? 'Internal Error',
});
} finally {
clearTimeout(this.timeout);
}
}

protected abstract processEvent(request: Request): Promise<Response | undefined>;

private respond(response: HandlerResponse) {
if (this.timedOut) {
return;
}
const cfResponse: AWSLambda.CloudFormationCustomResourceResponse = {
Status: response.status,
Reason: response.reason,
PhysicalResourceId: this.physicalResourceId,
StackId: this.event.StackId,
RequestId: this.event.RequestId,
LogicalResourceId: this.event.LogicalResourceId,
NoEcho: false,
Data: response.data,
};
const responseBody = JSON.stringify(cfResponse);

console.log('Responding to CloudFormation', responseBody);

const parsedUrl = url.parse(this.event.ResponseURL);
const requestOptions = {
hostname: parsedUrl.hostname,
path: parsedUrl.path,
method: 'PUT',
headers: { 'content-type': '', 'content-length': responseBody.length },
};

return new Promise((resolve, reject) => {
try {
const request = https.request(requestOptions, resolve);
request.on('error', reject);
request.write(responseBody);
request.end();
} catch (e) {
reject(e);
}
});
}
}

function extractPhysicalResourceId(event: AWSLambda.CloudFormationCustomResourceEvent): string {
switch (event.RequestType) {
case 'Create':
return event.LogicalResourceId;
case 'Update':
case 'Delete':
return event.PhysicalResourceId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { AssertionHandler } from './assertion';
import { ResultsCollectionHandler } from './results';
import { SdkHandler } from './sdk';
import * as types from './types';

export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent, context: AWSLambda.Context) {
const provider = createResourceHandler(event, context);
await provider.handle();
}

function createResourceHandler(event: AWSLambda.CloudFormationCustomResourceEvent, context: AWSLambda.Context) {
if (event.ResourceType.startsWith(types.SDK_RESOURCE_TYPE_PREFIX)) {
return new SdkHandler(event, context);
}
switch (event.ResourceType) {
case types.ASSERT_RESOURCE_TYPE: return new AssertionHandler(event, context);
case types.RESULTS_RESOURCE_TYPE: return new ResultsCollectionHandler(event, context);
default:
throw new Error(`Unsupported resource type "${event.ResourceType}`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { CustomResourceHandler } from './base';
import { ResultsCollectionRequest, ResultsCollectionResult } from './types';

export class ResultsCollectionHandler extends CustomResourceHandler<ResultsCollectionRequest, ResultsCollectionResult> {
protected async processEvent(request: ResultsCollectionRequest): Promise<ResultsCollectionResult | undefined> {
const reduced: string = request.assertionResults.reduce((agg, result, idx) => {
const msg = result.status === 'pass' ? 'pass' : `fail - ${result.message}`;
return `${agg}\nTest${idx}: ${msg}`;
}, '').trim();
return { message: reduced };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/* eslint-disable no-console */
import { CustomResourceHandler } from './base';
import { SdkRequest, SdkResult } from './types';

/**
* Flattens a nested object
*
* @param object the object to be flattened
* @returns a flat object with path as keys
*/
export function flatten(object: object): { [key: string]: any } {
return Object.assign(
{},
...function _flatten(child: any, path: string[] = []): any {
return [].concat(...Object.keys(child)
.map(key => {
const childKey = Buffer.isBuffer(child[key]) ? child[key].toString('utf8') : child[key];
return typeof childKey === 'object' && childKey !== null
? _flatten(childKey, path.concat([key]))
: ({ [path.concat([key]).join('.')]: childKey });
}));
}(object),
);
}


export class SdkHandler extends CustomResourceHandler<SdkRequest, SdkResult | { [key: string]: string }> {
protected async processEvent(request: SdkRequest): Promise<SdkResult | { [key: string]: string } | undefined> {
// eslint-disable-next-line
const AWS: any = require('aws-sdk');
console.log(`AWS SDK VERSION: ${AWS.VERSION}`);

const service = new AWS[request.service]();
const response = await service[request.api](request.parameters && decode(request.parameters)).promise();
console.log(`SDK response received ${JSON.stringify(response)}`);
delete response.ResponseMetadata;
const respond = {
apiCallResponse: response,
};
const flatData: { [key: string]: string } = {
...flatten(respond),
};

return request.flattenResponse === 'true' ? flatData : respond;
}
}

function decode(object: Record<string, unknown>) {
return JSON.parse(JSON.stringify(object), (_k, v) => {
switch (v) {
case 'TRUE:BOOLEAN':
return true;
case 'FALSE:BOOLEAN':
return false;
default:
return v;
}
});
}
Loading