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 }