Skip to content

Commit

Permalink
Refactor the custom resource provider as proposed in #9
Browse files Browse the repository at this point in the history
This change makes it such that each custom resource is split into 2 parts

The "resource provider" and instances of the custom resource, that refer to a given provider.

This also for the Lambda backed provider adds the ability to add custom permissions :)
  • Loading branch information
mindstorms6 committed Jun 3, 2018
1 parent 32ccb48 commit fb7641e
Show file tree
Hide file tree
Showing 6 changed files with 111 additions and 76 deletions.
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[];

This comment has been minimized.

Copy link
@eladb

eladb Jun 3, 2018

Contributor

I can't find a reason not to add this to LambdaProps. Looks like it might be useful in the general case.

}
/**
* 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;

This comment has been minimized.

Copy link
@eladb

eladb Jun 3, 2018

Contributor

Can we call this impl or implementation? Maybe handler?


/**
* 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 {

This comment has been minimized.

Copy link
@eladb

eladb Jun 3, 2018

Contributor

👍

// Needs to be implemented using inheritance because we must override the `renderProperties`

This comment has been minimized.

Copy link
@eladb

eladb Jun 3, 2018

Contributor

Delete comment

// 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

0 comments on commit fb7641e

Please sign in to comment.