github.com/mweagle/Sparta@v1.15.0/aws/cloudformation/resources/customResource.go (about)

     1  package resources
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"io/ioutil"
     8  	"net/http"
     9  	"os"
    10  	"strings"
    11  
    12  	awsLambdaCtx "github.com/aws/aws-lambda-go/lambdacontext"
    13  	"github.com/aws/aws-sdk-go/aws"
    14  	"github.com/aws/aws-sdk-go/aws/request"
    15  	"github.com/aws/aws-sdk-go/aws/session"
    16  	"github.com/aws/aws-sdk-go/service/cloudformation"
    17  	gocf "github.com/mweagle/go-cloudformation"
    18  	"github.com/pkg/errors"
    19  	"github.com/sirupsen/logrus"
    20  )
    21  
    22  const (
    23  	// CreateOperation is a request to create a resource
    24  	// @enum CloudFormationOperation
    25  	CreateOperation = "Create"
    26  	// DeleteOperation is a request to delete a resource
    27  	// @enum CloudFormationOperation
    28  	DeleteOperation = "Delete"
    29  	// UpdateOperation is a request to update a resource
    30  	// @enum CloudFormationOperation
    31  	UpdateOperation = "Update"
    32  )
    33  const (
    34  	// CustomResourceTypePrefix is the known custom resource
    35  	// type prefix
    36  	CustomResourceTypePrefix = "Custom::goAWS"
    37  )
    38  
    39  var (
    40  	// HelloWorld is the typename for HelloWorldResource
    41  	HelloWorld = cloudFormationResourceType("HelloWorldResource")
    42  	// S3LambdaEventSource is the typename for S3LambdaEventSourceResource
    43  	S3LambdaEventSource = cloudFormationResourceType("S3EventSource")
    44  	// SNSLambdaEventSource is the typename for SNSLambdaEventSourceResource
    45  	SNSLambdaEventSource = cloudFormationResourceType("SNSEventSource")
    46  	// CodeCommitLambdaEventSource is the type name for CodeCommitEventSourceResource
    47  	CodeCommitLambdaEventSource = cloudFormationResourceType("CodeCommitEventSource")
    48  	// SESLambdaEventSource is the typename for SESLambdaEventSourceResource
    49  	SESLambdaEventSource = cloudFormationResourceType("SESEventSource")
    50  	// CloudWatchLogsLambdaEventSource is the typename for SESLambdaEventSourceResource
    51  	CloudWatchLogsLambdaEventSource = cloudFormationResourceType("CloudWatchLogsEventSource")
    52  	// ZipToS3Bucket is the typename for ZipToS3Bucket
    53  	ZipToS3Bucket = cloudFormationResourceType("ZipToS3Bucket")
    54  	// S3ArtifactPublisher is the typename for publishing an S3Artifact
    55  	S3ArtifactPublisher = cloudFormationResourceType("S3ArtifactPublisher")
    56  )
    57  
    58  func customTypeProvider(resourceType string) gocf.ResourceProperties {
    59  	switch resourceType {
    60  	case HelloWorld:
    61  		return &HelloWorldResource{}
    62  	case S3LambdaEventSource:
    63  		return &S3LambdaEventSourceResource{}
    64  	case CloudWatchLogsLambdaEventSource:
    65  		return &CloudWatchLogsLambdaEventSourceResource{}
    66  	case CodeCommitLambdaEventSource:
    67  		return &CodeCommitLambdaEventSourceResource{}
    68  	case SNSLambdaEventSource:
    69  		return &SNSLambdaEventSourceResource{}
    70  	case SESLambdaEventSource:
    71  		return &SESLambdaEventSourceResource{}
    72  	case ZipToS3Bucket:
    73  		return &ZipToS3BucketResource{}
    74  	case S3ArtifactPublisher:
    75  		return &S3ArtifactPublisherResource{}
    76  	}
    77  	return nil
    78  }
    79  
    80  func init() {
    81  	gocf.RegisterCustomResourceProvider(customTypeProvider)
    82  }
    83  
    84  // CustomResourceCommand defines operations that a CustomResource must implement.
    85  type CustomResourceCommand interface {
    86  	Create(session *session.Session,
    87  		event *CloudFormationLambdaEvent,
    88  		logger *logrus.Logger) (map[string]interface{}, error)
    89  
    90  	Update(session *session.Session,
    91  		event *CloudFormationLambdaEvent,
    92  		logger *logrus.Logger) (map[string]interface{}, error)
    93  
    94  	Delete(session *session.Session,
    95  		event *CloudFormationLambdaEvent,
    96  		logger *logrus.Logger) (map[string]interface{}, error)
    97  }
    98  
    99  // CustomResourcePrivilegedCommand is a command that also has IAM privileges
   100  // which implies there must be an ARN associated with the command
   101  type CustomResourcePrivilegedCommand interface {
   102  	// The IAMPrivileges this command requires of the IAM role
   103  	IAMPrivileges() []string
   104  }
   105  
   106  // cloudFormationResourceType a string for the resource name that represents a
   107  // custom CloudFormation resource typename
   108  func cloudFormationResourceType(resType string) string {
   109  	return fmt.Sprintf("%s::%s", CustomResourceTypePrefix, resType)
   110  }
   111  
   112  type logrusProxy struct {
   113  	logger *logrus.Logger
   114  }
   115  
   116  func (proxy *logrusProxy) Log(args ...interface{}) {
   117  	proxy.logger.Info(args...)
   118  }
   119  
   120  // CloudFormationLambdaEvent is the event to a resource
   121  type CloudFormationLambdaEvent struct {
   122  	RequestType           string
   123  	RequestID             string `json:"RequestId"`
   124  	ResponseURL           string
   125  	ResourceType          string
   126  	StackID               string `json:"StackId"`
   127  	LogicalResourceID     string `json:"LogicalResourceId"`
   128  	ResourceProperties    json.RawMessage
   129  	OldResourceProperties json.RawMessage
   130  }
   131  
   132  // SendCloudFormationResponse sends the given response
   133  // to the CloudFormation URL that was submitted together
   134  // with this event
   135  func SendCloudFormationResponse(lambdaCtx *awsLambdaCtx.LambdaContext,
   136  	event *CloudFormationLambdaEvent,
   137  	results map[string]interface{},
   138  	responseErr error,
   139  	logger *logrus.Logger) error {
   140  
   141  	status := "FAILED"
   142  	if nil == responseErr {
   143  		status = "SUCCESS"
   144  	}
   145  	// Env vars:
   146  	// https://docs.aws.amazon.com/lambda/latest/dg/current-supported-versions.html
   147  	logGroupName := os.Getenv("AWS_LAMBDA_LOG_GROUP_NAME")
   148  	logStreamName := os.Getenv("AWS_LAMBDA_LOG_STREAM_NAME")
   149  	reasonText := ""
   150  	if nil != responseErr {
   151  		reasonText = fmt.Sprintf("%s. Details in CloudWatch Logs: %s : %s",
   152  			responseErr.Error(),
   153  			logGroupName,
   154  			logStreamName)
   155  	} else {
   156  		reasonText = fmt.Sprintf("Details in CloudWatch Logs: %s : %s",
   157  			logGroupName,
   158  			logStreamName)
   159  	}
   160  	// PhysicalResourceId
   161  	// This value should be an identifier unique to the custom resource vendor,
   162  	// and can be up to 1 Kb in size. The value must be a non-empty string and
   163  	// must be identical for all responses for the same resource.
   164  	// Ref: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requesttypes-create.html
   165  	physicalResourceID := fmt.Sprintf("LogStreamName: %s", logStreamName)
   166  	responseData := map[string]interface{}{
   167  		"Status":             status,
   168  		"Reason":             reasonText,
   169  		"PhysicalResourceId": physicalResourceID,
   170  		"StackId":            event.StackID,
   171  		"RequestId":          event.RequestID,
   172  		"LogicalResourceId":  event.LogicalResourceID,
   173  	}
   174  	if nil != responseErr {
   175  		responseData["Data"] = map[string]interface{}{
   176  			"Error": responseErr,
   177  		}
   178  	} else if nil != results {
   179  		responseData["Data"] = results
   180  	} else {
   181  		responseData["Data"] = map[string]interface{}{}
   182  	}
   183  
   184  	logger.WithFields(logrus.Fields{
   185  		"ResponsePayload": responseData,
   186  	}).Debug("Response Info")
   187  
   188  	jsonData, jsonError := json.Marshal(responseData)
   189  	if nil != jsonError {
   190  		return errors.Wrap(jsonError, "Attempting to marshal Cloudformation response")
   191  	}
   192  
   193  	responseBuffer := strings.NewReader(string(jsonData))
   194  	req, httpErr := http.NewRequest("PUT",
   195  		event.ResponseURL,
   196  		responseBuffer)
   197  
   198  	if nil != httpErr {
   199  		return httpErr
   200  	}
   201  	// Need to use the Opaque field b/c Go will parse inline encoded values
   202  	// which are supposed to be roundtripped to AWS.
   203  	// Ref: https://tools.ietf.org/html/rfc3986#section-2.2
   204  	// Ref: https://golang.org/pkg/net/url/#URL
   205  	// Ref: https://github.com/aws/aws-sdk-go/issues/337
   206  	// parsedURL, parsedURLErr := url.ParseRequestURI(event.ResponseURL)
   207  
   208  	// https://en.wikipedia.org/wiki/Percent-encoding
   209  	mapReplace := map[string]string{
   210  		":": "%3A",
   211  		"|": "%7C",
   212  	}
   213  	req.URL.Opaque = req.URL.Path
   214  	for eachKey, eachValue := range mapReplace {
   215  		req.URL.Opaque = strings.Replace(req.URL.Opaque, eachKey, eachValue, -1)
   216  	}
   217  	req.URL.Path = ""
   218  	req.URL.RawPath = ""
   219  
   220  	logger.WithFields(logrus.Fields{
   221  		"RawURL": event.ResponseURL,
   222  		"URL":    req.URL,
   223  		"Body":   responseData,
   224  	}).Debug("Created URL response")
   225  
   226  	// Although it seems reasonable to set the Content-Type to "application/json" - don't.
   227  	// The Content-Type must be an empty string in order for the
   228  	// AWS Signature checker to pass.
   229  	// Ref: http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html
   230  	req.Header.Set("content-type", "")
   231  
   232  	client := &http.Client{}
   233  	resp, httpErr := client.Do(req)
   234  	if httpErr != nil {
   235  		return errors.Wrapf(httpErr, "Sending CloudFormation response")
   236  	}
   237  	logger.WithFields(logrus.Fields{
   238  		"LogicalResourceId":  event.LogicalResourceID,
   239  		"Result":             responseData["Status"],
   240  		"ResponseStatusCode": resp.StatusCode,
   241  	}).Debug("Sent CloudFormation response")
   242  
   243  	if resp.StatusCode < 200 || resp.StatusCode > 299 {
   244  		body, bodyErr := ioutil.ReadAll(resp.Body)
   245  		if bodyErr != nil {
   246  			logger.Warn("Unable to read body: " + bodyErr.Error())
   247  			body = []byte{}
   248  		}
   249  		return errors.Errorf("Error sending response: %d. Data: %s", resp.StatusCode, string(body))
   250  	}
   251  	defer resp.Body.Close()
   252  	return nil
   253  }
   254  
   255  // Returns an AWS Session (https://github.com/aws/aws-sdk-go/wiki/Getting-Started-Configuration)
   256  // object that attaches a debug level handler to all AWS requests from services
   257  // sharing the session value.
   258  func awsSession(logger *logrus.Logger) *session.Session {
   259  	awsConfig := &aws.Config{
   260  		CredentialsChainVerboseErrors: aws.Bool(true),
   261  	}
   262  
   263  	// Log AWS calls if needed
   264  	switch logger.Level {
   265  	case logrus.DebugLevel:
   266  		awsConfig.LogLevel = aws.LogLevel(aws.LogDebugWithHTTPBody)
   267  	}
   268  	awsConfig.Logger = &logrusProxy{logger}
   269  	sess, sessionErr := session.NewSession(awsConfig)
   270  	if sessionErr != nil {
   271  		logger.WithField("Error", sessionErr).Warn("Failed to attach AWS Session logger")
   272  	} else {
   273  		sess.Handlers.Send.PushFront(func(r *request.Request) {
   274  			logger.WithFields(logrus.Fields{
   275  				"Service":   r.ClientInfo.ServiceName,
   276  				"Operation": r.Operation.Name,
   277  				"Method":    r.Operation.HTTPMethod,
   278  				"Path":      r.Operation.HTTPPath,
   279  				"Payload":   r.Params,
   280  			}).Debug("AWS Request")
   281  		})
   282  	}
   283  	return sess
   284  }
   285  
   286  // CloudFormationLambdaCustomResourceHandler is an adapter
   287  // function that transforms an implementing CustomResourceCommand
   288  // into something that that can respond to the lambda custom
   289  // resource lifecycle
   290  func CloudFormationLambdaCustomResourceHandler(command CustomResourceCommand, logger *logrus.Logger) interface{} {
   291  	return func(ctx context.Context,
   292  		event CloudFormationLambdaEvent) error {
   293  		lambdaCtx, lambdaCtxOk := awsLambdaCtx.FromContext(ctx)
   294  		if !lambdaCtxOk {
   295  			return errors.Errorf("Failed to access AWS Lambda Context from ctx argument")
   296  		}
   297  		customResourceSession := awsSession(logger)
   298  		var opResults map[string]interface{}
   299  		var opErr error
   300  		executeOperation := false
   301  		// If we're in cleanup mode, then skip it...
   302  		// Don't forward to the CustomAction handler iff we're in CLEANUP mode
   303  		describeStacksInput := &cloudformation.DescribeStacksInput{
   304  			StackName: aws.String(event.StackID),
   305  		}
   306  		cfSvc := cloudformation.New(customResourceSession)
   307  		describeStacksOutput, describeStacksOutputErr := cfSvc.DescribeStacks(describeStacksInput)
   308  		if nil != describeStacksOutputErr {
   309  			opErr = describeStacksOutputErr
   310  		} else {
   311  			stackDesc := describeStacksOutput.Stacks[0]
   312  			if stackDesc == nil {
   313  				opErr = errors.Errorf("DescribeStack failed: %s", event.StackID)
   314  			} else {
   315  				executeOperation = (*stackDesc.StackStatus != "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS")
   316  			}
   317  		}
   318  
   319  		logger.WithFields(logrus.Fields{
   320  			"ExecuteOperation": event.LogicalResourceID,
   321  			"Stacks":           fmt.Sprintf("%#+v", describeStacksOutput),
   322  			"RequestType":      event.RequestType,
   323  		}).Debug("CustomResource Request")
   324  
   325  		if opErr == nil && executeOperation {
   326  			switch event.RequestType {
   327  			case CreateOperation:
   328  				opResults, opErr = command.Create(customResourceSession, &event, logger)
   329  			case DeleteOperation:
   330  				opResults, opErr = command.Delete(customResourceSession, &event, logger)
   331  			case UpdateOperation:
   332  				opResults, opErr = command.Update(customResourceSession, &event, logger)
   333  			}
   334  		}
   335  		// Notify CloudFormation of the result
   336  		if event.ResponseURL != "" {
   337  			sendErr := SendCloudFormationResponse(lambdaCtx,
   338  				&event,
   339  				opResults,
   340  				opErr,
   341  				logger)
   342  			if nil != sendErr {
   343  				logger.WithFields(logrus.Fields{
   344  					"Error": sendErr.Error(),
   345  					"URL":   event.ResponseURL,
   346  				}).Info("Failed to ACK status to CloudFormation")
   347  			} else {
   348  				// If the cloudformation notification was complete, then this
   349  				// execution functioned properly and we can clear the Error
   350  				opErr = nil
   351  			}
   352  		}
   353  		return opErr
   354  	}
   355  }
   356  
   357  // NewCustomResourceLambdaHandler returns a handler for the given
   358  // type
   359  func NewCustomResourceLambdaHandler(resourceType string, logger *logrus.Logger) interface{} {
   360  
   361  	// TODO - eliminate this factory stuff and just register
   362  	// the custom resources as normal lambda handlers...
   363  	var lambdaCmd CustomResourceCommand
   364  	cfResource := customTypeProvider(resourceType)
   365  	if cfResource != nil {
   366  		cmd, cmdOK := cfResource.(CustomResourceCommand)
   367  		if cmdOK {
   368  			lambdaCmd = cmd
   369  		}
   370  	}
   371  	if lambdaCmd == nil {
   372  		return errors.Errorf("Custom resource handler not found for type: %s", resourceType)
   373  	}
   374  	return CloudFormationLambdaCustomResourceHandler(lambdaCmd, logger)
   375  }