-
Notifications
You must be signed in to change notification settings - Fork 17
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
Expose CloudFormation Custom Resource Emulator Resource #1807
Changes from all commits
c60d8ec
024669f
b031e32
29f7d9c
940e804
ae82c16
2cc9972
8c72236
773a5d6
fefca64
670e340
38a15c2
bdaa07a
438e2aa
fbf3f9f
f0d2ac8
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: cfn-custom-resource | ||
runtime: nodejs | ||
description: A TypeScript Pulumi program with AWS Cloud Control provider |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
/** | ||
* A sample Lambda function that looks up the latest AMI ID for a given region and architecture. | ||
**/ | ||
|
||
// Map instance architectures to an AMI name pattern | ||
var archToAMINamePattern = { | ||
"PV64": "amzn-ami-pv*x86_64-ebs", | ||
"HVM64": "al2023-ami-2023.*-kernel-*-x86_64", | ||
"HVMG2": "amzn-ami-graphics-hvm*x86_64-ebs*" | ||
}; | ||
const { EC2Client, DescribeImagesCommand } = require("@aws-sdk/client-ec2"); | ||
|
||
exports.handler = async function(event, context) { | ||
const redactedEvent = { ...event, ResponseURL: "REDACTED" }; | ||
console.log("REQUEST RECEIVED:\n" + JSON.stringify(redactedEvent)); | ||
|
||
// For Delete requests, immediately send a SUCCESS response. | ||
if (event.RequestType == "Delete") { | ||
await sendResponse(event, context, "SUCCESS"); | ||
return; | ||
} | ||
|
||
var responseStatus = "FAILED"; | ||
var responseData = {}; | ||
|
||
const ec2Client = new EC2Client({ region: event.ResourceProperties.Region }); | ||
const describeImagesParams = { | ||
Filters: [{ Name: "name", Values: [archToAMINamePattern[event.ResourceProperties.Architecture]]}], | ||
Owners: [event.ResourceProperties.Architecture == "HVMG2" ? "679593333241" : "amazon"] | ||
}; | ||
|
||
try { | ||
const describeImagesResult = await ec2Client.send(new DescribeImagesCommand(describeImagesParams)); | ||
var images = describeImagesResult.Images; | ||
// Sort images by name in descending order. The names contain the AMI version, formatted as YYYY.MM.Ver. | ||
images.sort((x, y) => y.Name.localeCompare(x.Name)); | ||
for (var j = 0; j < images.length; j++) { | ||
if (isBeta(images[j].Name)) continue; | ||
responseStatus = "SUCCESS"; | ||
responseData["Id"] = images[j].ImageId; | ||
break; | ||
} | ||
} catch (err) { | ||
responseData = { Error: "DescribeImages call failed" }; | ||
console.log(responseData.Error + ":\n", err); | ||
} | ||
|
||
await sendResponse(event, context, responseStatus, responseData); | ||
}; | ||
|
||
// Check if the image is a beta or rc image. The Lambda function won't return any of those images. | ||
function isBeta(imageName) { | ||
return imageName.toLowerCase().indexOf("beta") > -1 || imageName.toLowerCase().indexOf(".rc") > -1; | ||
} | ||
|
||
// Send response to the pre-signed S3 URL | ||
async function sendResponse(event, context, responseStatus, responseData) { | ||
var responseBody = JSON.stringify({ | ||
Status: responseStatus, | ||
Reason: "See the details in CloudWatch Log Stream: " + context.logStreamName, | ||
PhysicalResourceId: context.logStreamName, | ||
StackId: event.StackId, | ||
RequestId: event.RequestId, | ||
LogicalResourceId: event.LogicalResourceId, | ||
Data: responseData | ||
}); | ||
|
||
console.log("RESPONSE BODY:\n", responseBody); | ||
|
||
var https = require("https"); | ||
var url = require("url"); | ||
|
||
var parsedUrl = url.parse(event.ResponseURL); | ||
var options = { | ||
hostname: parsedUrl.hostname, | ||
port: 443, | ||
path: parsedUrl.path, | ||
method: "PUT", | ||
headers: { | ||
"content-type": "", | ||
"content-length": responseBody.length | ||
} | ||
}; | ||
|
||
console.log("SENDING RESPONSE...\n"); | ||
|
||
await new Promise((resolve, reject) => { | ||
var request = https.request(options, function(response) { | ||
console.log("STATUS: " + response.statusCode); | ||
console.log("HEADERS: " + JSON.stringify(response.headers)); | ||
resolve(); | ||
}); | ||
|
||
request.on("error", function(error) { | ||
console.log("sendResponse Error:" + error); | ||
reject(error); | ||
}); | ||
|
||
// write data to request body | ||
request.write(responseBody); | ||
request.end(); | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
// Copyright 2016-2024, Pulumi Corporation. | ||
|
||
import * as pulumi from '@pulumi/pulumi'; | ||
import * as aws from "@pulumi/aws-native"; | ||
import * as awsClassic from "@pulumi/aws"; | ||
|
||
const amiRegion = new pulumi.Config().require("amiRegion"); | ||
|
||
// Create an IAM role for the Lambda function | ||
const lambdaRole = new awsClassic.iam.Role("lambdaRole", { | ||
assumeRolePolicy: awsClassic.iam.assumeRolePolicyForPrincipal({ Service: "lambda.amazonaws.com" }), | ||
}); | ||
|
||
const policy = new awsClassic.iam.Policy("lambdaPolicy", { | ||
policy: { | ||
Version: "2012-10-17", | ||
Statement: [{ | ||
Action: "ec2:DescribeImages", | ||
Effect: "Allow", | ||
Resource: "*", | ||
}], | ||
}, | ||
}); | ||
|
||
const rpa1 = new awsClassic.iam.RolePolicyAttachment("lambdaRolePolicyAttachment1", { | ||
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. as a side note it's hilarious to me that CCAPI doesn't support these basic iam resources. How are you supposed to do anything in AWS without these 🙃 |
||
role: lambdaRole.name, | ||
policyArn: policy.arn, | ||
}); | ||
|
||
const rpa2 = new awsClassic.iam.RolePolicyAttachment("lambdaRolePolicyAttachment2", { | ||
role: lambdaRole.name, | ||
policyArn: awsClassic.iam.ManagedPolicies.AWSLambdaBasicExecutionRole, | ||
}); | ||
|
||
const bucket = new awsClassic.s3.BucketV2('custom-resource-emulator', { | ||
forceDestroy: true, | ||
}); | ||
|
||
const handlerCode = new awsClassic.s3.BucketObjectv2("handler-code", { | ||
bucket: bucket.bucket, | ||
key: "handlerCode", | ||
source: new pulumi.asset.AssetArchive({ | ||
"index.js": new pulumi.asset.FileAsset("ami-lookup.js"), | ||
}) | ||
}) | ||
|
||
// Create the Lambda function for the custom resource | ||
const lambdaFunction = new awsClassic.lambda.Function("ami-lookup-custom-resource", { | ||
runtime: awsClassic.types.enums.lambda.Runtime.NodeJS20dX, | ||
s3Bucket: bucket.bucket, | ||
s3Key: handlerCode.key, | ||
handler: "index.handler", | ||
role: lambdaRole.arn, | ||
memorySize: 128, | ||
timeout: 30, | ||
}, { dependsOn: [rpa1, rpa2] }); | ||
|
||
const cfnCustomResource = new aws.cloudformation.CustomResourceEmulator('emulator', { | ||
bucketName: bucket.id, | ||
bucketKeyPrefix: 'custom-resource-emulator', | ||
customResourceProperties: { | ||
Region: amiRegion, | ||
Architecture: 'HVM64', | ||
}, | ||
serviceToken: lambdaFunction.arn, | ||
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. Ahhh so serviceToken is not called lambdaFunctionARN because some custom resources are backed by something else? 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. Yeah, there's also SNS backed Custom Resources: #1812 They're not widely used, so we decided to skip support for them for now. It's not in scope of the CDK work 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. The naming makes a bit more sense now. |
||
resourceType: 'Custom::MyResource', | ||
}, { customTimeouts: { create: '5m', update: '5m', delete: '5m' } }); | ||
|
||
const cloudformationStack = new awsClassic.cloudformation.Stack('stack', { | ||
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. Nice! |
||
templateBody: pulumi.interpolate`{ | ||
"AWSTemplateFormatVersion" : "2010-09-09", | ||
|
||
"Description" : "AWS CloudFormation AMI Look Up Sample Template: Demonstrates how to dynamically specify an AMI ID. This template provisions an EC2 instance with an AMI ID that is based on the instance's type and region. **WARNING** This template creates an Amazon EC2 instance. You will be billed for the AWS resources used if you create a stack from this template.", | ||
|
||
"Resources" : { | ||
"AMIInfo": { | ||
"Type": "Custom::AMIInfo", | ||
"Properties": { | ||
"ServiceToken": "${lambdaFunction.arn}", | ||
"ServiceTimeout": 300, | ||
"Region": "${amiRegion}", | ||
"Architecture": "HVM64" | ||
} | ||
} | ||
}, | ||
|
||
"Outputs" : { | ||
"AMIID" : { | ||
"Description": "The Amazon EC2 instance AMI ID.", | ||
"Value" : { "Fn::GetAtt": [ "AMIInfo", "Id" ] } | ||
} | ||
} | ||
} | ||
` | ||
}); | ||
|
||
export const cloudformationAmiId = cloudformationStack.outputs['AMIID']; | ||
export const emulatorAmiId = cfnCustomResource.data['Id']; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
{ | ||
"name": "cfn-custom-resource", | ||
"devDependencies": { | ||
"@types/node": "^8.0.0" | ||
}, | ||
"dependencies": { | ||
"@pulumi/pulumi": "^3.136.0", | ||
"@pulumi/aws": "^6.57.0" | ||
}, | ||
"peerDependencies": { | ||
"@pulumi/aws-native": "dev" | ||
} | ||
} |
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 |
---|---|---|
|
@@ -8,7 +8,13 @@ import ( | |
"path/filepath" | ||
"testing" | ||
|
||
"github.com/pulumi/providertest/pulumitest" | ||
"github.com/pulumi/providertest/pulumitest/assertpreview" | ||
"github.com/pulumi/providertest/pulumitest/opttest" | ||
"github.com/pulumi/pulumi/pkg/v3/testing/integration" | ||
"github.com/pulumi/pulumi/sdk/v3/go/auto" | ||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
func TestSimpleTs(t *testing.T) { | ||
|
@@ -29,6 +35,46 @@ func TestGetTs(t *testing.T) { | |
integration.ProgramTest(t, &test) | ||
} | ||
|
||
func TestCustomResourceEmulator(t *testing.T) { | ||
crossTest := func(t *testing.T, outputs auto.OutputMap) { | ||
require.Contains(t, outputs, "cloudformationAmiId") | ||
cloudformationAmiId := outputs["cloudformationAmiId"].Value.(string) | ||
require.NotEmpty(t, cloudformationAmiId) | ||
|
||
require.Contains(t, outputs, "emulatorAmiId") | ||
emulatorAmiId := outputs["emulatorAmiId"].Value.(string) | ||
assert.Equal(t, cloudformationAmiId, emulatorAmiId) | ||
} | ||
|
||
cwd := getCwd(t) | ||
options := []opttest.Option{ | ||
opttest.LocalProviderPath("aws-native", filepath.Join(cwd, "..", "bin")), | ||
opttest.YarnLink("@pulumi/aws-native"), | ||
} | ||
test := pulumitest.NewPulumiTest(t, filepath.Join(cwd, "cfn-custom-resource"), options...) | ||
test.SetConfig(t, "amiRegion", "us-west-2") | ||
|
||
previewResult := test.Preview(t) | ||
flostadler marked this conversation as resolved.
Show resolved
Hide resolved
|
||
t.Logf("#%v", previewResult.ChangeSummary) | ||
|
||
upResult := test.Up(t) | ||
t.Logf("#%v", upResult.Summary) | ||
crossTest(t, upResult.Outputs) | ||
|
||
previewResult = test.Preview(t) | ||
assertpreview.HasNoChanges(t, previewResult) | ||
|
||
test.SetConfig(t, "amiRegion", "us-east-1") | ||
upResult = test.Up(t) | ||
t.Logf("#%v", upResult.Summary) | ||
crossTest(t, upResult.Outputs) | ||
|
||
previewResult = test.Preview(t) | ||
assertpreview.HasNoChanges(t, previewResult) | ||
|
||
test.Destroy(t) | ||
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. I think this is automatically done and not necessary to call explicitly. 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. Yeah it is, but I wanted to be explicit about this being one of the tested aspects. Happy to remove it though |
||
} | ||
|
||
func TestVpcCidrs(t *testing.T) { | ||
test := getJSBaseOptions(t). | ||
With(integration.ProgramTestOptions{ | ||
|
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.
I am curious here, when people write lambdas to power custom resources in CloudFormation, do they need to take care of this functionality in user code or is there some library or framework that users can call in CF?
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 needs to be part of the user code in the lambda function.
When using Inline Code in CloudFormation you can use the
cfn-response
module if it's a node Lambda. That's automatically included in the Lambda Zip when using inline code.https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html#cfn-lambda-function-code-cfnresponsemodule-source
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.
You could use it in the example too presumably? https://www.npmjs.com/package/cfn-response seems available. No big deal though
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.
That's not the official version. Somebody just extracted it (from a lambda function) and published it on Lambda. I wouldn't wanna rely on this in-official version for the example.
Not worth it for a simple http put request IMO