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

Expose CloudFormation Custom Resource Emulator Resource #1807

Merged
merged 16 commits into from
Nov 11, 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 examples/cfn-custom-resource/Pulumi.yaml
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
103 changes: 103 additions & 0 deletions examples/cfn-custom-resource/ami-lookup.js
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) {
Copy link
Member

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?

Copy link
Contributor Author

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

Copy link
Member

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

Copy link
Contributor Author

@flostadler flostadler Nov 11, 2024

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

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();
});
}
98 changes: 98 additions & 0 deletions examples/cfn-custom-resource/index.ts
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", {
Copy link
Contributor

Choose a reason for hiding this comment

The 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,
Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Member

Choose a reason for hiding this comment

The 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', {
Copy link
Contributor

Choose a reason for hiding this comment

The 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'];
13 changes: 13 additions & 0 deletions examples/cfn-custom-resource/package.json
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"
}
}
18 changes: 18 additions & 0 deletions examples/cfn-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"
]
}
46 changes: 46 additions & 0 deletions examples/examples_nodejs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Copy link
Member

Choose a reason for hiding this comment

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

I think this is automatically done and not necessary to call explicitly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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{
Expand Down
6 changes: 5 additions & 1 deletion examples/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ module github.com/pulumi/pulumi-aws-native/examples
go 1.21

require (
github.com/pulumi/providertest v0.1.3
github.com/pulumi/pulumi/pkg/v3 v3.138.0
github.com/pulumi/pulumi/sdk/v3 v3.138.0
github.com/stretchr/testify v1.9.0
)

Expand Down Expand Up @@ -61,6 +63,7 @@ require (
github.com/edsrzf/mmap-go v1.1.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/go-git/go-git/v5 v5.12.0 // indirect
Expand Down Expand Up @@ -115,6 +118,7 @@ require (
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/natefinch/atomic v1.0.1 // indirect
github.com/nxadm/tail v1.4.11 // indirect
github.com/opentracing/basictracer-go v1.1.0 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pgavlin/fx v0.1.6 // indirect
Expand All @@ -126,7 +130,6 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 // indirect
github.com/pulumi/esc v0.10.0 // indirect
github.com/pulumi/pulumi/sdk/v3 v3.138.0 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
Expand Down Expand Up @@ -173,6 +176,7 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
google.golang.org/grpc v1.67.1 // indirect
google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/frand v1.4.2 // indirect
Expand Down
Loading
Loading