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

Refactor the custom resource provider as proposed in #9 #13

Closed
wants to merge 1 commit into from
Closed
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
47 changes: 39 additions & 8 deletions packages/aws-cdk-custom-resources/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,59 @@ custom resource.
Sample of a Custom Resource that copies files into an S3 bucket during deployment
(implementation of actual `copy.py` operation elided).

The below example creates a new resource provider.

```ts
interface CopyOperationProps {
sourceBucket: IBucket;
targetBucket: IBucket;
}

class CopyOperation extends CustomResource {
constructor(parent: Construct, name: string, props: DemoResourceProps) {
class CopyResourceProvider extends CustomResource {
constructor(parent: Construct, name: string) {
super(parent, name, {
provider: new LambdaBackedCustomResource({
uuid: 'f7d4f730-4ee1-11e8-9c2d-fa7ae01bbebc',
lambdaProperties: {
code: new LambdaInlineCode(resources['copy.py']),
handler: 'index.handler',
timeout: 60,
runtime: LambdaRuntime.Python3,
permissions: [new PolicyStatement().addResource("*").addAction("s3:*")] //this is too broad and only for demo purposes
}
}),
properties: {
sourceBucketArn: props.sourceBucket.bucketArn,
targetBucketArn: props.targetBucket.bucketArn,
}
})
});
}

/**
* Overrides the parent resourceInstance method to take a specific type of props
**/
public resourceInstance(name: string, props: DemoResourceProps) {
return super.resourceInstance(name, props);
}
}
```

Then, you need only call `resourceInstance` to add an instance of your custom resource!

```ts
class MyAmazingStack extends Stack {
constructor(parent: App, name: string, props?: StackProps) {
super(parent, name, props);

const resource = new CopyResourceProvider(this, 'CopyResource');

const sourceBucket = ...;
const destBucket = ...;

const copyResourceInstance = resource.resourceInstance('CopyInstance1', {
sourceBucket,
destBucket
});

// Publish the custom resource output
new Output(this, 'DestPath', {
description: 'The path as returned by the custom resource instance',
value: copyResourceInstance.getAtt('DestPath')
});
}
}
Expand Down
29 changes: 16 additions & 13 deletions packages/aws-cdk-custom-resources/example/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { App, Construct, Output, Stack, StackProps, Token } from 'aws-cdk';
import { App, Construct, Output, Stack, StackProps } from 'aws-cdk';
import { LambdaInlineCode, LambdaRuntime } from 'aws-cdk-lambda';
import { s3 } from 'aws-cdk-resources';
import fs = require('fs');
Expand All @@ -17,12 +17,9 @@ interface DemoResourceProps {
}

class DemoResource extends CustomResource {
public readonly response: Token;

constructor(parent: Construct, name: string, props: DemoResourceProps) {
constructor(parent: Construct, name: string) {
super(parent, name, {
provider: new LambdaBackedCustomResource({
uuid: 'f7d4f730-4ee1-11e8-9c2d-fa7ae01bbebc',
provider: new LambdaBackedCustomResource(parent, 'LambdaProvider', {
lambdaProperties: {
// This makes the demo only work as top-level TypeScript program, but that's fine for now
code: new LambdaInlineCode(fs.readFileSync('provider.py', { encoding: 'utf-8' })),
Expand All @@ -31,10 +28,11 @@ class DemoResource extends CustomResource {
runtime: LambdaRuntime.Python27,
}
}),
properties: props
});
}

this.response = this.getAtt('Response');
public resourceInstance(name: string, props: DemoResourceProps) {
return super.resourceInstance(name, props);
}
}

Expand All @@ -45,14 +43,16 @@ class SucceedingStack extends Stack {
constructor(parent: App, name: string, props?: StackProps) {
super(parent, name, props);

const resource = new DemoResource(this, 'DemoResource', {
const resource = new DemoResource(this, 'DemoResource');

const demoResourceInstance = resource.resourceInstance('DemoResourceInstance', {
message: 'CustomResource says hello',
});

// Publish the custom resource output
new Output(this, 'ResponseMessage', {
description: 'The message that came back from the Custom Resource',
value: resource.response
value: demoResourceInstance.getAtt('Response')
});
}
}
Expand All @@ -64,7 +64,8 @@ class FailCreationStack extends Stack {
constructor(parent: App, name: string, props?: StackProps) {
super(parent, name, props);

new DemoResource(this, 'DemoResource', {
const resourceProvider = new DemoResource(this, 'DemoResource');
resourceProvider.resourceInstance('Instance', {
message: 'CustomResource is silent',
failCreate: true
});
Expand All @@ -79,7 +80,9 @@ class FailAfterCreatingStack extends Stack {
constructor(parent: App, name: string, props?: StackProps) {
super(parent, name, props);

const resource = new DemoResource(this, 'DemoResource', {
const resourceProvider = new DemoResource(this, 'DemoResource');

const resourceInstance = resourceProvider.resourceInstance('DemoResourceInstance', {
message: 'CustomResource says hello',
});

Expand All @@ -89,7 +92,7 @@ class FailAfterCreatingStack extends Stack {
});

// Make sure the rollback gets triggered only after the custom resource has been fully created.
bucket.addDependency(resource);
bucket.addDependency(resourceInstance);
}
}

Expand Down
50 changes: 16 additions & 34 deletions packages/aws-cdk-custom-resources/lib/provider.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,47 @@
import { Stack, Token } from "aws-cdk";
import { Construct, PolicyStatement, Token } from "aws-cdk";
import { Lambda, LambdaProps } from 'aws-cdk-lambda';

/**
* Base class for Custom Resource providers, that details how the custom resource is created
*/
export abstract class CustomResourceImplementation {
export interface CustomResourceImplementation {
/**
* Return the provider ID for the provider in the given stack
*
* Returns either a Lambda ARN or an SNS topic ARN.
*/
public abstract providerArn(stack: Stack): Token;
providerArn(): Token;
}

/**
* Properties to pass to a Lambda-backed custom resource provider
*/
export interface LambdaBackedCustomResourceProps {
/**
* A unique identifier to identify this lambda
*
* The identifier should be unique across all custom resource providers.
* We recommend generating a UUID per provider.
*/
uuid: string;

/**
* Properties to instantiate the Lambda
*/
lambdaProperties: LambdaProps;
lambdaProperties: LambdaPropsWithPermissions;
}

export interface LambdaPropsWithPermissions extends LambdaProps {
permissions?: PolicyStatement[];
}
/**
* Custom Resource implementation that is backed by a Lambda function
*/
export class LambdaBackedCustomResource extends CustomResourceImplementation {
constructor(private readonly props: LambdaBackedCustomResourceProps) {
super();
}
export class LambdaBackedCustomResource implements CustomResourceImplementation {

public providerArn(stack: Stack): Token {
const providerLambda = this.ensureLambda(stack);
return providerLambda.functionArn;
}
private readonly lambda: Lambda;

/**
* Add a fresh Lambda to the stack, or return the existing one if it already exists
*/
private ensureLambda(stack: Stack): Lambda {
const name = slugify(this.props.uuid);
const existing = stack.tryFindChild(name);
if (existing) {
// Just assume this is true
return existing as Lambda;
constructor(parent: Construct, name: string, private readonly props: LambdaBackedCustomResourceProps) {
this.lambda = new Lambda(parent, name, this.props.lambdaProperties);
if (this.props.lambdaProperties.permissions && this.props.lambdaProperties.permissions.length > 0) {
this.props.lambdaProperties.permissions.forEach(permission => this.lambda.addToRolePolicy(permission));
}

const newFunction = new Lambda(stack, name, this.props.lambdaProperties);
return newFunction;
}
}

function slugify(x: string): string {
return x.replace(/[^a-zA-Z0-9]/g, '');
public providerArn(): Token {
return this.lambda.functionArn;
}
}
47 changes: 32 additions & 15 deletions packages/aws-cdk-custom-resources/lib/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,6 @@ export interface CustomResourceProps {
* The provider that is going to implement this custom resource
*/
provider: CustomResourceImplementation;

/**
* Properties to pass to the Lambda
*/
properties?: Properties;
}

/**
Expand All @@ -29,29 +24,51 @@ export interface CustomResourceProps {
* that hides the choice of provider, and accepts a strongly-typed properties
* object with the properties your provider accepts.
*/
export class CustomResource extends cloudformation.CustomResource {
export class CustomResource extends Construct {
// Needs to be implemented using inheritance because we must override the `renderProperties`
// The generated props classes will never render properties that they don't know about.
private readonly stack: Stack;
private readonly provider: CustomResourceImplementation;

constructor(parent: Construct, name: string, props: CustomResourceProps) {
super(parent, name);
this.stack = Stack.find(parent);
this.provider = props.provider;
}

/**
* Add a new instance of the custom resource to the stack
*/
public resourceInstance(name: string, properties?: Properties) {
return new CustomResourceInstance(this, name, {
stack: this.stack,
provider: this.provider,
userProperties: properties}
);
}
}

export interface CustomResourceInstanceProps {
stack: Stack,
provider: CustomResourceImplementation,
userProperties?: Properties,
}

export class CustomResourceInstance extends cloudformation.CustomResource {

private readonly userProperties?: Properties;

constructor(parent: Construct, name: string, props: CustomResourceProps) {
const stack = Stack.find(parent);
constructor(parent: CustomResource, name: string, properties: CustomResourceInstanceProps) {
super(parent, name, {
serviceToken: props.provider.providerArn(stack),
serviceToken: properties.provider.providerArn()
});

this.userProperties = props.properties;
this.userProperties = properties.userProperties;
}

/**
* Override renderProperties to mix in the user-defined properties
*/
protected renderProperties(): {[key: string]: any} {
const props = super.renderProperties();
return Object.assign(props, uppercaseProperties(this.userProperties || {}));
}

}

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/aws-cdk-custom-resources/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"aws-cdk": "^0.6.0",
"aws-cdk-iam": "^0.6.0",
"aws-cdk-lambda": "^0.6.0",
"aws-cdk-resources": "^0.6.0"
"aws-cdk-resources": "^0.6.0",
"@types/uuid": "^3.2.1"
}
}
11 changes: 6 additions & 5 deletions packages/aws-cdk-custom-resources/test/test.resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import { CustomResource, LambdaBackedCustomResource } from '../lib';
// tslint:disable:object-literal-key-quotes

export = {
'custom resource is added twice, lambda is added once'(test: Test) {
'custom resource is instantiated twice, lambda is added once'(test: Test) {
// GIVEN
const stack = new Stack();

// WHEN
new TestCustomResource(stack, 'Custom1');
new TestCustomResource(stack, 'Custom2');
const resourceProvider = new TestCustomResource(stack, 'Why');

resourceProvider.resourceInstance("Custom1");
resourceProvider.resourceInstance("Custom2");

// THEN
expect(stack).toMatch({
Expand Down Expand Up @@ -89,8 +91,7 @@ export = {
class TestCustomResource extends CustomResource {
constructor(parent: Construct, name: string) {
super(parent, name, {
provider: new LambdaBackedCustomResource({
uuid: 'TestCustomResourceProvider',
provider: new LambdaBackedCustomResource(parent, 'TestCustomResourceProvider', {
lambdaProperties: {
code: new LambdaInlineCode('def hello(): pass'),
runtime: LambdaRuntime.Python27,
Expand Down