Skip to content

Commit

Permalink
Expose CloudFormation Custom Resource Emulator Resource (#1807)
Browse files Browse the repository at this point in the history
This change exposes the new CloudFormation Custom Resource Emulator
resource.
Additionally, it adds an integration test for it and makes `Check`
correctly handle unknowns.

One follow up item is to translate the code example to other languages.
  • Loading branch information
flostadler authored Nov 11, 2024
1 parent 14a21ae commit 7968d1b
Show file tree
Hide file tree
Showing 23 changed files with 1,298 additions and 158 deletions.
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) {
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", {
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,
resourceType: 'Custom::MyResource',
}, { customTimeouts: { create: '5m', update: '5m', delete: '5m' } });

const cloudformationStack = new awsClassic.cloudformation.Stack('stack', {
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)
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)
}

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

0 comments on commit 7968d1b

Please sign in to comment.