-
Notifications
You must be signed in to change notification settings - Fork 5
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
Add support for CDK Custom Resources #190
Changes from all commits
1829734
e6b7f14
9125669
de29b3f
193247d
de8771e
9c960bb
e6bd66a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
name: pulumi-custom-resource | ||
runtime: nodejs | ||
description: CDK custom resource example |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
import * as pulumi from '@pulumi/pulumi'; | ||
import * as pulumicdk from '@pulumi/cdk'; | ||
|
||
import * as cdk from 'aws-cdk-lib'; | ||
import * as s3 from 'aws-cdk-lib/aws-s3'; | ||
import * as iam from 'aws-cdk-lib/aws-iam'; | ||
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment'; | ||
|
||
class S3DeploymentStack extends pulumicdk.Stack { | ||
bucketWebsiteUrl: pulumi.Output<string>; | ||
bucketObjectKeys: pulumi.Output<string[]>; | ||
|
||
constructor(app: pulumicdk.App, id: string, options?: pulumicdk.StackOptions) { | ||
super(app, id, options); | ||
|
||
const bucket = new s3.Bucket(this, 'WebsiteBucket', { | ||
websiteIndexDocument: 'index.html', | ||
publicReadAccess: true, | ||
blockPublicAccess: { | ||
blockPublicAcls: false, | ||
blockPublicPolicy: false, | ||
ignorePublicAcls: false, | ||
restrictPublicBuckets: false, | ||
}, | ||
removalPolicy: cdk.RemovalPolicy.DESTROY, | ||
autoDeleteObjects: true, | ||
}); | ||
|
||
|
||
this.bucketWebsiteUrl = this.asOutput(bucket.bucketWebsiteUrl); | ||
|
||
const deploy = new s3deploy.BucketDeployment(this, 'DeployWebsite', { | ||
sources: [s3deploy.Source.data('index.html', 'Hello, World!')], | ||
destinationBucket: bucket, | ||
}); | ||
|
||
this.bucketObjectKeys = this.asOutput(deploy.objectKeys); | ||
|
||
// Create a role that can read from the deployment bucket | ||
// This verifies that GetAtt works on Custom Resources | ||
new iam.Role(this, 'CustomResourceRole', { | ||
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), | ||
inlinePolicies: { | ||
'CustomResourcePolicy': new iam.PolicyDocument({ | ||
statements: [ | ||
new iam.PolicyStatement({ | ||
actions: ['s3:GetObject'], | ||
resources: [`${deploy.deployedBucket.bucketArn}/*`], | ||
}), | ||
], | ||
}), | ||
} | ||
}); | ||
} | ||
} | ||
|
||
const cfg = new pulumi.Config(); | ||
const accountId = cfg.require('accountId'); | ||
|
||
class MyApp extends pulumicdk.App { | ||
constructor() { | ||
super('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { | ||
const stack = new S3DeploymentStack(scope, 's3deployment', { | ||
// configure the environment to prevent the bucket from using the unsupported FindInMap intrinsic (TODO[pulumi/pulumi-cdk#187]) | ||
props: { | ||
env: { | ||
account: accountId, | ||
region: process.env.AWS_REGION, | ||
} | ||
} | ||
}); | ||
return { | ||
bucketWebsiteUrl: stack.bucketWebsiteUrl, | ||
bucketObjectKeys: stack.bucketObjectKeys | ||
}; | ||
}); | ||
} | ||
} | ||
|
||
const app = new MyApp(); | ||
export const websiteUrl = app.outputs['bucketWebsiteUrl']; | ||
export const objectKeys = app.outputs['bucketObjectKeys']; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
{ | ||
"name": "pulumi-aws-cdk", | ||
"devDependencies": { | ||
"@types/node": "^10.0.0" | ||
}, | ||
"dependencies": { | ||
"@pulumi/aws": "^6.0.0", | ||
"@pulumi/aws-native": "^1.0.0", | ||
"@pulumi/pulumi": "^3.0.0", | ||
"aws-cdk-lib": "2.149.0", | ||
"constructs": "10.3.0", | ||
"@pulumi/cdk": "^0.5.0", | ||
"@aws-sdk/client-s3": "^3.677.0", | ||
"@aws-sdk/s3-request-presigner": "^3.677.0", | ||
"@aws-sdk/client-lambda": "^3.677.0" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
{ | ||
"compilerOptions": { | ||
"strict": true, | ||
"outDir": "bin", | ||
"target": "es2016", | ||
"module": "commonjs", | ||
"moduleResolution": "node", | ||
"sourceMap": true, | ||
"experimentalDecorators": true, | ||
"pretty": true, | ||
"noFallthroughCasesInSwitch": true, | ||
"noImplicitReturns": true, | ||
"forceConsistentCasingInFileNames": true | ||
}, | ||
"files": [ | ||
"index.ts" | ||
] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -116,7 +116,19 @@ export function mapToCfnResource( | |
const mName = moduleName(typeName).toLowerCase(); | ||
const pType = pulumiTypeName(typeName); | ||
const awsModule = aws as any; | ||
return new awsModule[mName][pType](logicalId, props, options); | ||
|
||
// Workaround until TODO[pulumi/pulumi-aws-native#1816] is resolved. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Without this none of the CDK CustomResources I tried deploy successfully. They are all nested too deep for the 64 char limits of Lambda Functions and IAM roles |
||
// Not expected to be exposed to users | ||
let name = logicalId; | ||
const maxNameLength = process.env.PULUMI_CDK_EXPERIMENTAL_MAX_NAME_LENGTH; | ||
if (maxNameLength) { | ||
const maxLength = parseInt(maxNameLength, 10); | ||
if (name.length > maxLength) { | ||
name = name.substring(0, maxLength); | ||
} | ||
} | ||
|
||
return new awsModule[mName][pType](name, props, options); | ||
} | ||
} | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
// Copyright 2016-2024, Pulumi Corporation. | ||
// | ||
// Licensed under the Apache License, Version 2.0 (the "License"); | ||
// you may not use this file except in compliance with the License. | ||
// You may obtain a copy of the License at | ||
// | ||
// http://www.apache.org/licenses/LICENSE-2.0 | ||
// | ||
// Unless required by applicable law or agreed to in writing, software | ||
// distributed under the License is distributed on an "AS IS" BASIS, | ||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
// See the License for the specific language governing permissions and | ||
// limitations under the License. | ||
|
||
import * as pulumi from '@pulumi/pulumi'; | ||
import * as aws from '@pulumi/aws-native'; | ||
import { ResourceMapping } from './interop'; | ||
import { Stack } from 'aws-cdk-lib/core'; | ||
import { PulumiSynthesizerBase } from './synthesizer'; | ||
import { debug } from '@pulumi/pulumi/log'; | ||
|
||
export function mapToCustomResource( | ||
logicalId: string, | ||
typeName: string, | ||
rawProps: any, | ||
options: pulumi.ResourceOptions, | ||
stack: Stack, | ||
): ResourceMapping | undefined { | ||
debug(`mapToCustomResource typeName: ${typeName} props: ${JSON.stringify(rawProps)}`); | ||
|
||
if (isCustomResource(typeName)) { | ||
const synth = stack.synthesizer; | ||
if (!(synth instanceof PulumiSynthesizerBase)) { | ||
throw new Error(`Synthesizer of stack ${stack.node.id} does not support custom resources. It must inherit from ${PulumiSynthesizerBase.name}.`); | ||
} | ||
|
||
const stagingBucket = synth.getStagingBucket(); | ||
const stackId = stack.node.id; | ||
|
||
return new aws.cloudformation.CustomResourceEmulator(logicalId, { | ||
stackId: stack.node.id, | ||
bucketName: stagingBucket, | ||
bucketKeyPrefix: `${synth.getDeployTimePrefix()}pulumi/custom-resources/${stackId}/${logicalId}`, | ||
serviceToken: rawProps.ServiceToken, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to check that ServiceToken is not undefined? |
||
resourceType: typeName, | ||
customResourceProperties: rawProps, | ||
}, { | ||
...options, | ||
customTimeouts: convertToCustomTimeouts(rawProps.ServiceTimeout), | ||
}); | ||
} | ||
|
||
return undefined; | ||
} | ||
|
||
function convertToCustomTimeouts(seconds?: number): pulumi.CustomTimeouts | undefined { | ||
if (seconds === undefined) { | ||
return undefined; | ||
} | ||
const duration = `${seconds}s`; | ||
return { | ||
create: duration, | ||
update: duration, | ||
delete: duration, | ||
}; | ||
} | ||
|
||
/** | ||
* Determines if the given type name corresponds to a custom resource. | ||
* Custom resources either use AWS::CloudFormation::CustomResource or Custom::MyCustomResourceTypeName for the type. | ||
*/ | ||
function isCustomResource(typeName: string): boolean { | ||
return typeName === 'AWS::CloudFormation::CustomResource' || typeName.startsWith('Custom::'); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -49,6 +49,13 @@ | |
* Will be undefined for the construct representing the `Stack` | ||
*/ | ||
parent?: ConstructInfo; | ||
|
||
constructInfo?: ConstructMetadata; | ||
} | ||
|
||
export interface ConstructMetadata { | ||
"fqn": string; | ||
"version": string; | ||
} | ||
|
||
export interface GraphNode { | ||
|
@@ -175,6 +182,7 @@ | |
path: tree.path, | ||
type: tree.constructInfo ? typeFromFqn(tree.constructInfo.fqn) : tree.id, | ||
attributes: tree.attributes, | ||
constructInfo: tree.constructInfo, | ||
}; | ||
const node: GraphNode = { | ||
incomingEdges: new Set<GraphNode>(), | ||
|
@@ -203,13 +211,33 @@ | |
if (resource.Type === 'AWS::EC2::VPC') { | ||
this.vpcNodes[node.logicalId] = { vpcNode: node, vpcCidrBlockNode: undefined }; | ||
} | ||
} else if (node.construct.constructInfo?.fqn === 'aws-cdk-lib.CfnResource') { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Previously we were missing resources that were not in the tree. We need #186 to validate all resources of the template get mapped. |
||
// If the construct is a CfnResource, then we need to treat it as a resource | ||
const logicalId = this.stack.logicalIdForPath(tree.path); | ||
const resource = this.stack.resourceWithLogicalId(logicalId); | ||
node.resource = resource; | ||
node.logicalId = logicalId; | ||
this.cfnElementNodes.set(logicalId, node); | ||
|
||
// Custom Resources do not map to types. E.g. Custom::Bucket should not map to Bucket | ||
if (!GraphBuilder.isCustomResource(tree, parent)) { | ||
node.construct.type = typeFromCfn(resource.Type); | ||
} | ||
} | ||
|
||
this.constructNodes.set(construct, node); | ||
if (tree.children) { | ||
Object.values(tree.children).forEach((child) => this.parseTree(child, construct)); | ||
} | ||
} | ||
|
||
private static isCustomResource(node: ConstructTree, parent?: ConstructInfo): boolean { | ||
// CDK CustomResource are exposed as a CfnResource with the ID "Default" | ||
// If the parent construct has the fqn of CustomResource and the current tree node is the "Default" node | ||
// then we need to treat it as a Custom Resource | ||
return parent?.constructInfo?.fqn === 'aws-cdk-lib.CustomResource' && node.id === 'Default' | ||
} | ||
|
||
private _build(): Graph { | ||
// passes | ||
// 1. collect all constructs into a map from construct name to DAG node, converting CFN elements to fragments | ||
|
@@ -289,7 +317,7 @@ | |
sorted.push(node); | ||
} | ||
|
||
for (const [_, node] of this.constructNodes) { | ||
sort(node); | ||
} | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is really nice.