Skip to content

Commit

Permalink
PR feedback
Browse files Browse the repository at this point in the history
	* Simplify new resource creation
	* Embed custom type in `ResourceProperties` to work around crewjam/go-cloudformation#9
	* Support CloudFormation _ResponseURL_ signaling iff defined
	* Add test case to directly invoke resource `create` method

TODO:
	* Lift op handler into retry loop
  • Loading branch information
mweagle committed Apr 15, 2016
1 parent 6929842 commit f89be73
Show file tree
Hide file tree
Showing 4 changed files with 217 additions and 82 deletions.
42 changes: 24 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
# CloudFormationResources
Catalog of golang-based CloudFormation CustomResources


## Adding a New Resource

1. Create a new struct that implements `CustomResourceCommand`
- This struct **MUST** embed `gocf.CloudFormationCustomResource` as in:

1. Create a new struct that embeds `CustomResourceCommand`
- This struct **MUST** embed `GoAWSCustomResource` as in:
```
type HelloWorldResource struct {
gocf.CloudFormationCustomResource
GoAWSCustomResource
Message string
}
```
2. Add a const representing the resource type to the _CustomResourceType_ enum eg, `HelloWorld`
- The literal **MUST** start with [Custom::goAWS](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cfn-customresource.html)
3. Add the new type to the `customResources` map in the `init` function in <i>cloud_formation_resources.go</i>
4. Add a new case to the `switch/case` in `Process()` that initializes the custom resource and assigns the new value to the `commandInstance` as in:
```
case HelloWorld:
customCommand := HelloWorldResource{}
if err := json.Unmarshal([]byte(string(marshaledProperties)), &customCommand); nil != err {
return nil, fmt.Errorf("Failed to unmarshal ResourceProperties for %s", request.ResourceType)
}
commandInstance = customCommand
```
2. Add a package level _var_ denoting the resource type
- The value **MUST** be generated by `cloudFormationResourceType(...)` to include the proper custom resource [prefix](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-custom-resources.html). Example:
```
HelloWorld = cloudFormationResourceType("HelloWorldResource")
```
3. Add a `case` label in `customCommandForTypeName` for the custom resource type added in step 2.
- This block is responsible for creating a new command instance and unmarshalling the _properties_ into the type-specific values.
- Assign the new command to the _customCommand_ interface. Example:
```
case HelloWorld:
command := HelloWorldResource{
GoAWSCustomResource: GoAWSCustomResource{
GoAWSType: resourceTypeName,
},
}
if nil != properties {
unmarshalError = json.Unmarshal([]byte(string(*properties)), &command)
}
customCommand = &command
}
```
215 changes: 164 additions & 51 deletions cloud_formation_resources.go
Original file line number Diff line number Diff line change
@@ -1,30 +1,55 @@
package cloudformationresources

import (
"bytes"
"encoding/json"
"fmt"
"reflect"
"io/ioutil"
"net/http"
"net/url"
"strconv"

"github.com/Sirupsen/logrus"
"github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/aws/session"
gocf "github.com/crewjam/go-cloudformation"
)

// CloudFormationLambdaEvent represents the event data sent during a
// Lambda invocation in the context of a CloudFormation operation.
// Ref: http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html
type CloudFormationLambdaEvent struct {
RequestType string
ResponseURL string
StackID string `json:"StackId"`
RequestID string `json:"RequestId"`
ResourceType string
LogicalResourceID string `json:"LogicalResourceId"`
PhysicalResourceID string `json:"PhysicalResourceId"`
ResourceProperties map[string]interface{}
OldResourceProperties map[string]interface{}
}

// CustomResourceRequest is the go representation of the CloudFormation resource
// request
type CustomResourceRequest struct {
RequestType string
ServiceToken string
ResponseURL string
StackID string `json:"StackId"`
RequestID string `json:"RequestId"`
LogicalResourceID string `json:"LogicalResourceId"`
ResourceType string
ResourceProperties map[string]interface{}
}

// CustomResourceCommand defines a CloudFormation resource
// GoAWSCustomResource does SpartaApplication-S3CustomResourced9468234fca3ffb5
type GoAWSCustomResource struct {
gocf.CloudFormationCustomResource
GoAWSType string
}

// CustomResourceCommand defines operations that a CustomResource must implement.
// The return values are either operation outputs or an error value that should
// be used in the response to the CloudFormation AWS Lambda response.
type CustomResourceCommand interface {
create(session *session.Session,
logger *logrus.Logger) (map[string]interface{}, error)
Expand All @@ -48,31 +73,53 @@ const (
UpdateOperation = "Update"
)

const (
// HelloWorld is a simple Hello World resource that accepts a single "Message" resource property
// @enum CustomResourceType
HelloWorld = "Custom::goAWS::HelloWorld"
var (
// HelloWorld is the typename for HelloWorldResource
HelloWorld = cloudFormationResourceType("HelloWorldResource")
)

func customCommandForTypeName(resourceTypeName string, properties *[]byte) (interface{}, error) {
var unmarshalError error
var customCommand interface{}
// ---------------------------------------------------------------------------
// BEGIN - RESOURCE TYPES
switch resourceTypeName {
case HelloWorld:
command := HelloWorldResource{
GoAWSCustomResource: GoAWSCustomResource{
GoAWSType: resourceTypeName,
},
}
if nil != properties {
unmarshalError = json.Unmarshal([]byte(string(*properties)), &command)
}
customCommand = &command
}
// END - RESOURCE TYPES
// ---------------------------------------------------------------------------

if unmarshalError != nil {
return nil, fmt.Errorf("Failed to unmarshal properties for type: %s", resourceTypeName)
}
if nil == customCommand {
return nil, fmt.Errorf("Failed to create custom command for type: %s", resourceTypeName)
}
return customCommand, nil
}

func customTypeProvider(resourceType string) gocf.ResourceProperties {
typeRef, ok := customResources[resourceType]
if !ok {
commandInstance, commandError := customCommandForTypeName(resourceType, nil)
if nil != commandError {
return nil
}
customResourceElem := reflect.New(typeRef).Elem().Interface()
resProperties, ok := customResourceElem.(gocf.ResourceProperties)
resProperties, ok := commandInstance.(gocf.ResourceProperties)
if !ok {
return nil
}
return resProperties
}

var customResources map[string]reflect.Type

func init() {
// Setup the map
customResources = make(map[string]reflect.Type, 8)
customResources[HelloWorld] = reflect.TypeOf(HelloWorldResource{})
gocf.RegisterCustomResourceProvider(customTypeProvider)
}

Expand All @@ -93,51 +140,117 @@ func awsSession(logger *logrus.Logger) *session.Session {
return sess
}

// Handle processes the given CustomResourceRequest value
func Handle(request *CustomResourceRequest) (map[string]interface{}, error) {
logger := logrus.New()
logger.Formatter = new(logrus.JSONFormatter)
session := awsSession(logger)
// cloudFormationResourceType a string for the resource name that represents a
// custom CloudFormation resource typename
func cloudFormationResourceType(resType string) string {
return fmt.Sprintf("Custom::goAWS::%s", resType)
}

if _, exists := customResources[request.ResourceType]; !exists {
types := make([]string, len(customResources))
for eachKey := range customResources {
types = append(types, eachKey)
}
return nil, fmt.Errorf("Unregistered CloudFormation CustomResource type requested: <%s>. Registered types: %s", request.ResourceType, types)
func sendCloudFormationResponse(customResourceRequest *CustomResourceRequest,
results map[string]interface{},
responseErr error, logger *logrus.Logger) error {

parsedURL, parsedURLErr := url.ParseRequestURI(customResourceRequest.ResponseURL)
if nil != parsedURLErr {
return parsedURLErr
}
marshaledProperties, err := json.Marshal(request.ResourceProperties)
if nil != err {
return nil, err

status := "FAILED"
if nil == responseErr {
status = "SUCCESS"
}
responseData := map[string]interface{}{
"Status": status,
"Reason": fmt.Sprintf("See the details in the CloudWatch Log Stream"),
"PhysicalResourceId": customResourceRequest.LogicalResourceID,
"StackId": customResourceRequest.StackID,
"RequestId": customResourceRequest.RequestID,
"LogicalResourceId": customResourceRequest.LogicalResourceID,
"Data": results,
}
jsonData, jsonError := json.Marshal(responseData)
if nil != jsonError {
return jsonError
}
responseBuffer := bytes.NewBuffer(jsonData)
req, httpErr := http.NewRequest("PUT", customResourceRequest.ResponseURL, responseBuffer)
if nil != httpErr {
return httpErr
}
// Need to use the Opaque field b/c Go will parse inline encoded values
// which are supposed to be roundtripped to AWS.
// Ref: https://tools.ietf.org/html/rfc3986#section-2.2
// Ref: https://golang.org/pkg/net/url/#URL
req.URL = &url.URL{
Scheme: parsedURL.Scheme,
Host: parsedURL.Host,
Opaque: parsedURL.RawPath,
RawQuery: parsedURL.RawQuery,
}
var commandInstance CustomResourceCommand
// Although it seems reasonable to set the Content-Type to "application/json" - don't.
// The Content-Type must be an empty string in order for the
// AWS Signature checker to pass.
// Ref: http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html
req.Header.Set("Content-Type", "")
req.Header.Set("Content-Length", strconv.Itoa(responseBuffer.Len()))

//
// INSERT NEW RESOURCES HERE
///
// Create the appropriate type
switch request.ResourceType {
case HelloWorld:
customCommand := HelloWorldResource{}
if err := json.Unmarshal([]byte(string(marshaledProperties)), &customCommand); nil != err {
return nil, fmt.Errorf("Failed to unmarshal ResourceProperties for %s", request.ResourceType)
}
commandInstance = customCommand
default:
return nil, fmt.Errorf("Unsupported resource type: %s", request.ResourceType)
logger.WithFields(logrus.Fields{
"ResponseURL": req.URL,
}).Debug("CloudFormation ResponseURL")

client := &http.Client{}
resp, httpErr := client.Do(req)
if httpErr != nil {
return httpErr
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
body, _ := ioutil.ReadAll(resp.Body)
return fmt.Errorf("Error sending response: %d. Data: %s", resp.StatusCode, string(body))
}
if nil == commandInstance {
return nil, fmt.Errorf("Failed to create commandInstance for type: %s", request.ResourceType)
return nil
}

// Handle processes the given CustomResourceRequest value
func Handle(request *CustomResourceRequest, logger *logrus.Logger) error {
session := awsSession(logger)

logger.WithFields(logrus.Fields{
"Request": request,
}).Debug("Incoming request")

marshaledProperties, marshalError := json.Marshal(request.ResourceProperties)
if nil != marshalError {
return marshalError
}

commandTypeName := request.ResourceProperties["GoAWSType"].(string)
commandInstance, commandError := customCommandForTypeName(commandTypeName, &marshaledProperties)
if nil != commandError {
return commandError
}

// TODO - lift this into a backoff/retry loop
customCommandHandler := commandInstance.(CustomResourceCommand)
var operationOutputs map[string]interface{}
var operationError error
switch request.RequestType {
case CreateOperation:
return commandInstance.create(session, logger)
operationOutputs, operationError = customCommandHandler.create(session, logger)
case DeleteOperation:
return commandInstance.delete(session, logger)
operationOutputs, operationError = customCommandHandler.delete(session, logger)
case UpdateOperation:
return commandInstance.update(session, logger)
operationOutputs, operationError = customCommandHandler.update(session, logger)
default:
return nil, fmt.Errorf("Unsupported operation: %s", request.RequestType)
operationError = fmt.Errorf("Unsupported operation: %s", request.RequestType)
}
if "" != request.ResponseURL {
sendErr := sendCloudFormationResponse(request, operationOutputs, operationError, logger)
if nil != sendErr {
logger.WithFields(logrus.Fields{
"Error": sendErr,
}).Error("Failed to notify CloudFormation of result.")
}
}
return operationError
}
13 changes: 7 additions & 6 deletions hello_world_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,30 @@ package cloudformationresources
import (
"github.com/Sirupsen/logrus"
"github.com/aws/aws-sdk-go/aws/session"
gocf "github.com/crewjam/go-cloudformation"
)

// HelloWorldResource is a simple POC showing how to create custom resources
type HelloWorldResource struct {
gocf.CloudFormationCustomResource
GoAWSCustomResource
Message string
}

func (command HelloWorldResource) create(session *session.Session,
logger *logrus.Logger) (map[string]interface{}, error) {
logger.Info("Hello: ", command.Message)
return nil, nil
logger.Info("create: Hello ", command.Message)
return map[string]interface{}{
"Resource": "Created message: " + command.Message,
}, nil
}

func (command HelloWorldResource) update(session *session.Session,
logger *logrus.Logger) (map[string]interface{}, error) {
logger.Info("Nice to see you again: ", command.Message)
logger.Info("update: ", command.Message)
return nil, nil
}

func (command HelloWorldResource) delete(session *session.Session,
logger *logrus.Logger) (map[string]interface{}, error) {
logger.Info("Goodbye: ", command.Message)
logger.Info("delete: ", command.Message)
return nil, nil
}
Loading

0 comments on commit f89be73

Please sign in to comment.