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

Add support for CDK Custom Resources #190

Merged
merged 8 commits into from
Nov 15, 2024
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
3 changes: 3 additions & 0 deletions integration/custom-resource/Pulumi.yaml
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
82 changes: 82 additions & 0 deletions integration/custom-resource/index.ts
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'];
17 changes: 17 additions & 0 deletions integration/custom-resource/package.json
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"
}
}
18 changes: 18 additions & 0 deletions integration/custom-resource/tsconfig.json
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"
]
}
43 changes: 43 additions & 0 deletions integration/examples_nodejs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ import (
"bytes"
"path/filepath"
"testing"
"time"

"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/pulumi/pulumi/pkg/v3/testing/integration"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestApiGateway(t *testing.T) {
Expand Down Expand Up @@ -132,6 +136,45 @@ func TestErrors(t *testing.T) {
assert.Containsf(t, buf.String(), "Error: Event Bus policy statements must have a sid", "Expected error message not found in pulumi up output")
}

// TestCustomResource tests that CloudFormation Custom Resources work as expected. The test deploys two custom resources. One for cleaning the
// S3 bucket on delete and another for uploading the index.html file for a static website to the bucket.
// The test validates that the website is deployed, displays the expected content and gets cleaned up on delete.
func TestCustomResource(t *testing.T) {
sess := session.Must(session.NewSessionWithOptions(session.Options{
SharedConfigState: session.SharedConfigEnable,
}))
svc := sts.New(sess)

result, err := svc.GetCallerIdentity(&sts.GetCallerIdentityInput{})
require.NoError(t, err, "Failed to get AWS account ID")
accountId := *result.Account

test := getJSBaseOptions(t).
With(integration.ProgramTestOptions{
Dir: filepath.Join(getCwd(t), "custom-resource"),
Config: map[string]string{
"accountId": accountId,
},
// Workaround until TODO[pulumi/pulumi-aws-native#1816] is resolved.
Env: []string{"PULUMI_CDK_EXPERIMENTAL_MAX_NAME_LENGTH=56"},
ExtraRuntimeValidation: func(t *testing.T, stack integration.RuntimeValidationStackInfo) {
t.Logf("Outputs: %v", stack.Outputs)
url := stack.Outputs["websiteUrl"].(string)
assert.NotEmpty(t, url)

// Validate that the index.html file is deployed
integration.AssertHTTPResultWithRetry(t, url, nil, 60*time.Second, func(body string) bool {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really nice.

return assert.Equal(t, "Hello, World!", body, "Body should equal 'Hello, World!', got %s", body)
})

objectKeys := stack.Outputs["objectKeys"].([]interface{})
assert.NotEmpty(t, objectKeys)
},
})

integration.ProgramTest(t, &test)
}

func TestReplaceOnChanges(t *testing.T) {
test := getJSBaseOptions(t).
With(integration.ProgramTestOptions{
Expand Down
1 change: 0 additions & 1 deletion src/assembly/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { ConstructInfo } from 'aws-cdk-lib/core/lib/private/runtime-info';
import { Node } from 'aws-cdk-lib/core/lib/private/tree-metadata';
import { DockerImageManifestEntry, FileManifestEntry } from 'cdk-assets';

Expand Down
14 changes: 13 additions & 1 deletion src/cfn-resource-mappings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Contributor Author

@flostadler flostadler Nov 15, 2024

Choose a reason for hiding this comment

The 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);
}
}
}
Expand Down
26 changes: 25 additions & 1 deletion src/converters/app-converter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as cdk from 'aws-cdk-lib/core';
import * as aws from '@pulumi/aws-native'
import * as pulumi from '@pulumi/pulumi';
import { AssemblyManifestReader, StackManifest } from '../assembly';
import { ConstructInfo, Graph, GraphBuilder, GraphNode } from '../graph';
Expand All @@ -21,7 +22,8 @@
import { OutputMap, OutputRepr } from '../output-map';
import { parseSub } from '../sub';
import { getPartition } from '@pulumi/aws-native/getPartition';
import { processSecretsManagerReferenceValue, resolveSecretsManagerDynamicReference } from './secrets-manager-dynamic';
import { mapToCustomResource } from '../custom-resource-mapping';
import { processSecretsManagerReferenceValue } from './secrets-manager-dynamic';

/**
* AppConverter will convert all CDK resources into Pulumi resources.
Expand Down Expand Up @@ -288,6 +290,12 @@
return awsMapping;
}

const customResourceMapping = mapToCustomResource(logicalId, typeName, props, options, this.cdkStack);
if (customResourceMapping !== undefined) {
debug(`mapped ${logicalId} to custom resource(s)`);
return customResourceMapping;
}

const cfnMapping = mapToCfnResource(logicalId, typeName, props, options);
debug(`mapped ${logicalId} to native AWS resource(s)`);
return cfnMapping;
Expand Down Expand Up @@ -361,7 +369,7 @@
// and eventually get to the point where we have a string that looks like:
// "{{resolve:secretsmanager:arn:aws:secretsmanager:us-east-2:12345678910:secret:somesecretid-abcd:SecretString:password:AWSCURRENT}}"
return Object.entries(obj)
.filter(([_, v]) => !this.isNoValue(v))

Check warning on line 372 in src/converters/app-converter.ts

View workflow job for this annotation

GitHub Actions / Run lint

'_' is defined but never used
.reduce((result, [k, v]) => {
let value = this.processIntrinsics(v);
value = processSecretsManagerReferenceValue(this.stackResource, value);
Expand Down Expand Up @@ -431,7 +439,7 @@

case 'Fn::Sub':
return lift((params) => {
const [template, vars] =

Check warning on line 442 in src/converters/app-converter.ts

View workflow job for this annotation

GitHub Actions / Run lint

'vars' is assigned a value but never used
typeof params === 'string' ? [params, undefined] : [params[0] as string, params[1]];

const parts: string[] = [];
Expand Down Expand Up @@ -501,6 +509,9 @@
const map = <Mapping<pulumi.Resource>>mapping;
if (map.attributes && 'id' in map.attributes) {
return map.attributes.id;
} else if (aws.cloudformation.CustomResourceEmulator.isInstance(map.resource)) {
// Custom resources have a `physicalResourceId` that is used for Ref
return map.resource.physicalResourceId;
}
return (<pulumi.CustomResource>(<Mapping<pulumi.Resource>>mapping).resource).id;
}
Expand Down Expand Up @@ -529,6 +540,19 @@
// If this resource has explicit attribute mappings, those mappings will use PascalCase, not camelCase.
const propertyName = mapping.attributes !== undefined ? attribute : attributePropertyName(attribute);

// CFN CustomResources have a `data` property that contains the attributes. It is part of the response
// of the Lambda Function backing the Custom Resource.
if (aws.cloudformation.CustomResourceEmulator.isInstance(mapping.resource)) {
return mapping.resource.data.apply((attrs) => {
const descs = Object.getOwnPropertyDescriptors(attrs);
const d = descs[attribute];
if (!d) {
throw new Error(`No attribute ${attribute} on custom resource ${logicalId}`);
}
return d.value;
});
}

const descs = Object.getOwnPropertyDescriptors(mapping.attributes || mapping.resource);
const d = descs[propertyName];
if (!d) {
Expand Down
74 changes: 74 additions & 0 deletions src/custom-resource-mapping.ts
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,
Copy link
Member

Choose a reason for hiding this comment

The 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::');
}
28 changes: 28 additions & 0 deletions src/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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>(),
Expand Down Expand Up @@ -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') {
Copy link
Contributor Author

@flostadler flostadler Nov 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously we were missing resources that were not in the tree.
I noticed this because CDK CustomResources label the CfnCustomResource with the id Default. This is a special ID that causes those resources to be ignored 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
Expand Down Expand Up @@ -289,7 +317,7 @@
sorted.push(node);
}

for (const [_, node] of this.constructNodes) {

Check warning on line 320 in src/graph.ts

View workflow job for this annotation

GitHub Actions / Run lint

'_' is assigned a value but never used
sort(node);
}

Expand Down
Loading
Loading