github.com/mweagle/Sparta@v1.15.0/aws/cloudformation/util.go (about) 1 package cloudformation 2 3 import ( 4 "bytes" 5 "crypto/sha1" 6 "encoding/hex" 7 "encoding/json" 8 "fmt" 9 "io" 10 "io/ioutil" 11 "math/rand" 12 "os" 13 "regexp" 14 "sort" 15 "strconv" 16 "strings" 17 "text/template" 18 "time" 19 20 "github.com/aws/aws-sdk-go/aws" 21 "github.com/aws/aws-sdk-go/aws/session" 22 "github.com/aws/aws-sdk-go/service/cloudformation" 23 "github.com/aws/aws-sdk-go/service/s3/s3manager" 24 "github.com/briandowns/spinner" 25 humanize "github.com/dustin/go-humanize" 26 gocf "github.com/mweagle/go-cloudformation" 27 "github.com/pkg/errors" 28 "github.com/sirupsen/logrus" 29 ) 30 31 //var cacheLock sync.Mutex 32 33 func init() { 34 rand.Seed(time.Now().Unix()) 35 } 36 37 // RE to ensure CloudFormation compatible resource names 38 // Issue: https://github.com/mweagle/Sparta/issues/8 39 // Ref: http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/resources-section-structure.html 40 var reCloudFormationInvalidChars = regexp.MustCompile("[^A-Za-z0-9]+") 41 42 // maximum amount of time allowed for polling CloudFormation 43 var cloudformationPollingTimeout = 3 * time.Minute 44 45 //////////////////////////////////////////////////////////////////////////////// 46 // Private 47 //////////////////////////////////////////////////////////////////////////////// 48 49 // If the platform specific implementation of user.Current() 50 // isn't available, go get something that's a "stable" user 51 // name 52 func defaultUserName() string { 53 userName := os.Getenv("USER") 54 if userName == "" { 55 userName = os.Getenv("USERNAME") 56 } 57 if userName == "" { 58 userName = fmt.Sprintf("user%d", os.Getuid()) 59 } 60 return userName 61 } 62 63 type resourceProvisionMetrics struct { 64 resourceType string 65 logicalResourceID string 66 startTime time.Time 67 endTime time.Time 68 elapsed time.Duration 69 } 70 71 // BEGIN - templateConverter 72 // Struct to encapsulate transforming data into 73 type templateConverter struct { 74 templateReader io.Reader 75 additionalTemplateProps map[string]interface{} 76 // internals 77 doQuote bool 78 expandedTemplate string 79 contents []gocf.Stringable 80 conversionError error 81 } 82 83 func (converter *templateConverter) expandTemplate() *templateConverter { 84 if nil != converter.conversionError { 85 return converter 86 } 87 templateDataBytes, templateDataErr := ioutil.ReadAll(converter.templateReader) 88 if nil != templateDataErr { 89 converter.conversionError = templateDataErr 90 return converter 91 } 92 templateData := string(templateDataBytes) 93 94 parsedTemplate, templateErr := template.New("CloudFormation").Parse(templateData) 95 if nil != templateErr { 96 converter.conversionError = templateDataErr 97 return converter 98 } 99 output := &bytes.Buffer{} 100 executeErr := parsedTemplate.Execute(output, converter.additionalTemplateProps) 101 if nil != executeErr { 102 converter.conversionError = executeErr 103 return converter 104 } 105 converter.expandedTemplate = output.String() 106 return converter 107 } 108 109 func (converter *templateConverter) parseData() *templateConverter { 110 if converter.conversionError != nil { 111 return converter 112 } 113 // TODO - parse this better... 🤔 114 // First see if it's JSON...if so, just walk it 115 reAWSProp := regexp.MustCompile("\\{\\s*\"\\s*(Ref|Fn::GetAtt|Fn::FindInMap)") 116 splitData := strings.Split(converter.expandedTemplate, "\n") 117 splitDataLineCount := len(splitData) 118 119 for eachLineIndex, eachLine := range splitData { 120 curContents := eachLine 121 for len(curContents) != 0 { 122 123 matchInfo := reAWSProp.FindStringSubmatchIndex(curContents) 124 if nil != matchInfo { 125 // If there's anything at the head, push it. 126 if matchInfo[0] != 0 { 127 head := curContents[0:matchInfo[0]] 128 converter.contents = append(converter.contents, gocf.String(head)) 129 curContents = curContents[len(head):] 130 } 131 132 // There's at least one match...find the closing brace... 133 var parsed map[string]interface{} 134 for indexPos, eachChar := range curContents { 135 if string(eachChar) == "}" { 136 testBlock := curContents[0 : indexPos+1] 137 err := json.Unmarshal([]byte(testBlock), &parsed) 138 if err == nil { 139 parsedContents, parsedContentsErr := parseFnJoinExpr(parsed) 140 if nil != parsedContentsErr { 141 converter.conversionError = parsedContentsErr 142 return converter 143 } 144 if converter.doQuote { 145 converter.contents = append(converter.contents, gocf.Join("", 146 gocf.String("\""), 147 parsedContents, 148 gocf.String("\""))) 149 } else { 150 converter.contents = append(converter.contents, parsedContents) 151 } 152 curContents = curContents[indexPos+1:] 153 if len(curContents) <= 0 && (eachLineIndex < (splitDataLineCount - 1)) { 154 converter.contents = append(converter.contents, gocf.String("\n")) 155 } 156 break 157 } 158 } 159 } 160 if nil == parsed { 161 // We never did find the end... 162 converter.conversionError = fmt.Errorf("invalid CloudFormation JSON expression on line: %s", eachLine) 163 return converter 164 } 165 } else { 166 // No match, just include it iff there is another line afterwards 167 newlineValue := "" 168 if eachLineIndex < (splitDataLineCount - 1) { 169 newlineValue = "\n" 170 } 171 // Always include a newline at a minimum 172 appendLine := fmt.Sprintf("%s%s", curContents, newlineValue) 173 if len(appendLine) != 0 { 174 converter.contents = append(converter.contents, gocf.String(appendLine)) 175 } 176 break 177 } 178 } 179 } 180 return converter 181 } 182 183 func (converter *templateConverter) results() (*gocf.StringExpr, error) { 184 if nil != converter.conversionError { 185 return nil, converter.conversionError 186 } 187 return gocf.Join("", converter.contents...), nil 188 } 189 190 // END - templateConverter 191 192 func cloudformationPollingDelay() time.Duration { 193 return time.Duration(3+rand.Int31n(5)) * time.Second 194 } 195 196 func updateStackViaChangeSet(serviceName string, 197 cfTemplate *gocf.Template, 198 cfTemplateURL string, 199 awsTags []*cloudformation.Tag, 200 awsCloudFormation *cloudformation.CloudFormation, 201 logger *logrus.Logger) error { 202 203 // Create a change set name... 204 changeSetRequestName := CloudFormationResourceName(fmt.Sprintf("%sChangeSet", serviceName)) 205 _, changesErr := CreateStackChangeSet(changeSetRequestName, 206 serviceName, 207 cfTemplate, 208 cfTemplateURL, 209 awsTags, 210 awsCloudFormation, 211 logger) 212 if nil != changesErr { 213 return changesErr 214 } 215 216 ////////////////////////////////////////////////////////////////////////////// 217 // Apply the change 218 executeChangeSetInput := cloudformation.ExecuteChangeSetInput{ 219 ChangeSetName: aws.String(changeSetRequestName), 220 StackName: aws.String(serviceName), 221 } 222 executeChangeSetOutput, executeChangeSetError := awsCloudFormation.ExecuteChangeSet(&executeChangeSetInput) 223 224 logger.WithFields(logrus.Fields{ 225 "ExecuteChangeSetOutput": executeChangeSetOutput, 226 }).Debug("ExecuteChangeSet result") 227 228 if nil == executeChangeSetError { 229 logger.WithFields(logrus.Fields{ 230 "StackName": serviceName, 231 }).Info("Issued ExecuteChangeSet request") 232 } 233 return executeChangeSetError 234 235 } 236 237 // func existingLambdaResourceVersions(serviceName string, 238 // lambdaResourceName string, 239 // session *session.Session, 240 // logger *logrus.Logger) (*lambda.ListVersionsByFunctionOutput, error) { 241 242 // errorIsNotExist := func(apiError error) bool { 243 // return apiError != nil && strings.Contains(apiError.Error(), "does not exist") 244 // } 245 246 // logger.WithFields(logrus.Fields{ 247 // "ResourceName": lambdaResourceName, 248 // }).Info("Fetching existing function versions") 249 250 // cloudFormationSvc := cloudformation.New(session) 251 // describeParams := &cloudformation.DescribeStackResourceInput{ 252 // StackName: aws.String(serviceName), 253 // LogicalResourceId: aws.String(lambdaResourceName), 254 // } 255 // describeResponse, describeResponseErr := cloudFormationSvc.DescribeStackResource(describeParams) 256 // logger.WithFields(logrus.Fields{ 257 // "Response": describeResponse, 258 // "ResponseErr": describeResponseErr, 259 // }).Debug("Describe response") 260 // if errorIsNotExist(describeResponseErr) { 261 // return nil, nil 262 // } else if describeResponseErr != nil { 263 // return nil, describeResponseErr 264 // } 265 266 // listVersionsParams := &lambda.ListVersionsByFunctionInput{ 267 // FunctionName: describeResponse.StackResourceDetail.PhysicalResourceId, 268 // MaxItems: aws.Int64(128), 269 // } 270 // lambdaSvc := lambda.New(session) 271 // listVersionsResp, listVersionsRespErr := lambdaSvc.ListVersionsByFunction(listVersionsParams) 272 // if errorIsNotExist(listVersionsRespErr) { 273 // return nil, nil 274 // } else if listVersionsRespErr != nil { 275 // return nil, listVersionsRespErr 276 // } 277 // logger.WithFields(logrus.Fields{ 278 // "Response": listVersionsResp, 279 // "ResponseErr": listVersionsRespErr, 280 // }).Debug("ListVersionsByFunction") 281 // return listVersionsResp, nil 282 // } 283 284 func toExpressionSlice(input interface{}) ([]string, error) { 285 var expressions []string 286 slice, sliceOK := input.([]interface{}) 287 if !sliceOK { 288 return nil, fmt.Errorf("failed to convert to slice") 289 } 290 for _, eachValue := range slice { 291 switch str := eachValue.(type) { 292 case string: 293 expressions = append(expressions, str) 294 } 295 } 296 return expressions, nil 297 } 298 func parseFnJoinExpr(data map[string]interface{}) (*gocf.StringExpr, error) { 299 if len(data) <= 0 { 300 return nil, fmt.Errorf("data for FnJoinExpr is empty") 301 } 302 for eachKey, eachValue := range data { 303 switch eachKey { 304 case "Ref": 305 return gocf.Ref(eachValue.(string)).String(), nil 306 case "Fn::GetAtt": 307 attrValues, attrValuesErr := toExpressionSlice(eachValue) 308 if nil != attrValuesErr { 309 return nil, attrValuesErr 310 } 311 if len(attrValues) != 2 { 312 return nil, fmt.Errorf("invalid params for Fn::GetAtt: %s", eachValue) 313 } 314 return gocf.GetAtt(attrValues[0], attrValues[1]).String(), nil 315 case "Fn::FindInMap": 316 attrValues, attrValuesErr := toExpressionSlice(eachValue) 317 if nil != attrValuesErr { 318 return nil, attrValuesErr 319 } 320 if len(attrValues) != 3 { 321 return nil, fmt.Errorf("invalid params for Fn::FindInMap: %s", eachValue) 322 } 323 return gocf.FindInMap(attrValues[0], gocf.String(attrValues[1]), gocf.String(attrValues[2])), nil 324 } 325 } 326 return nil, fmt.Errorf("unsupported AWS Function detected: %#v", data) 327 } 328 329 func stackCapabilities(template *gocf.Template) []*string { 330 capabilitiesMap := make(map[string]bool) 331 332 // Only require IAM capability if the definition requires it. 333 for _, eachResource := range template.Resources { 334 if eachResource.Properties.CfnResourceType() == "AWS::IAM::Role" { 335 capabilitiesMap["CAPABILITY_IAM"] = true 336 switch typedResource := eachResource.Properties.(type) { 337 case gocf.IAMRole: 338 capabilitiesMap["CAPABILITY_NAMED_IAM"] = (typedResource.RoleName != nil) 339 case *gocf.IAMRole: 340 capabilitiesMap["CAPABILITY_NAMED_IAM"] = (typedResource.RoleName != nil) 341 } 342 } 343 } 344 capabilities := make([]*string, len(capabilitiesMap)) 345 capabilitiesIndex := 0 346 for eachKey := range capabilitiesMap { 347 capabilities[capabilitiesIndex] = aws.String(eachKey) 348 capabilitiesIndex++ 349 } 350 return capabilities 351 } 352 353 //////////////////////////////////////////////////////////////////////////////// 354 // Public 355 //////////////////////////////////////////////////////////////////////////////// 356 357 // DynamicValueToStringExpr is a DRY function to type assert 358 // a potentiall dynamic value into a gocf.Stringable 359 // satisfying type 360 func DynamicValueToStringExpr(dynamicValue interface{}) gocf.Stringable { 361 var stringExpr gocf.Stringable 362 switch typedValue := dynamicValue.(type) { 363 case string: 364 stringExpr = gocf.String(typedValue) 365 case *gocf.StringExpr: 366 stringExpr = typedValue 367 case gocf.Stringable: 368 stringExpr = typedValue.String() 369 default: 370 panic(fmt.Sprintf("Unsupported dynamic value type: %+v", typedValue)) 371 } 372 return stringExpr 373 } 374 375 // S3AllKeysArnForBucket returns a CloudFormation-compatible Arn expression 376 // (string or Ref) for all bucket keys (`/*`). The bucket 377 // parameter may be either a string or an interface{} ("Ref: "myResource") 378 // value 379 func S3AllKeysArnForBucket(bucket interface{}) *gocf.StringExpr { 380 arnParts := []gocf.Stringable{ 381 gocf.String("arn:aws:s3:::"), 382 DynamicValueToStringExpr(bucket), 383 gocf.String("/*"), 384 } 385 return gocf.Join("", arnParts...).String() 386 } 387 388 // S3ArnForBucket returns a CloudFormation-compatible Arn expression 389 // (string or Ref) suitable for template reference. The bucket 390 // parameter may be either a string or an interface{} ("Ref: "myResource") 391 // value 392 func S3ArnForBucket(bucket interface{}) *gocf.StringExpr { 393 arnParts := []gocf.Stringable{ 394 gocf.String("arn:aws:s3:::"), 395 DynamicValueToStringExpr(bucket), 396 } 397 return gocf.Join("", arnParts...).String() 398 } 399 400 // MapToResourceTags transforms a go map[string]string to a CloudFormation-compliant 401 // Tags representation. See http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-resource-tags.html 402 func MapToResourceTags(tagMap map[string]string) []interface{} { 403 tags := make([]interface{}, len(tagMap)) 404 tagsIndex := 0 405 for eachKey, eachValue := range tagMap { 406 tags[tagsIndex] = map[string]interface{}{ 407 "Key": eachKey, 408 "Value": eachValue, 409 } 410 tagsIndex++ 411 } 412 return tags 413 } 414 415 // ConvertToTemplateExpression transforms the templateData contents into 416 // an Fn::Join- compatible representation for template serialization. 417 // The templateData contents may include both golang text/template properties 418 // and single-line JSON Fn::Join supported serializations. 419 func ConvertToTemplateExpression(templateData io.Reader, 420 additionalUserTemplateProperties map[string]interface{}) (*gocf.StringExpr, error) { 421 converter := &templateConverter{ 422 templateReader: templateData, 423 additionalTemplateProps: additionalUserTemplateProperties, 424 } 425 return converter.expandTemplate().parseData().results() 426 } 427 428 // ConvertToInlineJSONTemplateExpression transforms the templateData contents into 429 // an Fn::Join- compatible inline JSON representation for template serialization. 430 // The templateData contents may include both golang text/template properties 431 // and single-line JSON Fn::Join supported serializations. 432 func ConvertToInlineJSONTemplateExpression(templateData io.Reader, 433 additionalUserTemplateProperties map[string]interface{}) (*gocf.StringExpr, error) { 434 converter := &templateConverter{ 435 templateReader: templateData, 436 additionalTemplateProps: additionalUserTemplateProperties, 437 doQuote: true, 438 } 439 return converter.expandTemplate().parseData().results() 440 } 441 442 // StackEvents returns the slice of cloudformation.StackEvents for the given stackID or stackName 443 func StackEvents(stackID string, 444 eventFilterLowerBoundInclusive time.Time, 445 awsSession *session.Session) ([]*cloudformation.StackEvent, error) { 446 447 cfService := cloudformation.New(awsSession) 448 var events []*cloudformation.StackEvent 449 450 nextToken := "" 451 for { 452 params := &cloudformation.DescribeStackEventsInput{ 453 StackName: aws.String(stackID), 454 } 455 if len(nextToken) > 0 { 456 params.NextToken = aws.String(nextToken) 457 } 458 459 resp, err := cfService.DescribeStackEvents(params) 460 if nil != err { 461 return nil, err 462 } 463 for _, eachEvent := range resp.StackEvents { 464 if eachEvent.Timestamp.Equal(eventFilterLowerBoundInclusive) || 465 eachEvent.Timestamp.After(eventFilterLowerBoundInclusive) { 466 events = append(events, eachEvent) 467 } 468 } 469 if nil == resp.NextToken { 470 break 471 } else { 472 nextToken = *resp.NextToken 473 } 474 } 475 return events, nil 476 } 477 478 // WaitForStackOperationCompleteResult encapsulates the stackInfo 479 // following a WaitForStackOperationComplete call 480 type WaitForStackOperationCompleteResult struct { 481 operationSuccessful bool 482 stackInfo *cloudformation.Stack 483 } 484 485 // WaitForStackOperationComplete is a blocking, polling based call that 486 // periodically fetches the stackID set of events and uses the state value 487 // to determine if an operation is complete 488 func WaitForStackOperationComplete(stackID string, 489 pollingMessage string, 490 awsCloudFormation *cloudformation.CloudFormation, 491 logger *logrus.Logger) (*WaitForStackOperationCompleteResult, error) { 492 493 result := &WaitForStackOperationCompleteResult{} 494 495 startTime := time.Now() 496 497 // Startup a spinner... 498 // TODO: special case iTerm per https://github.com/briandowns/spinner/issues/64 499 charSetIndex := 39 500 if strings.Contains(os.Getenv("LC_TERMINAL"), "iTerm") { 501 charSetIndex = 7 502 } 503 cliSpinner := spinner.New(spinner.CharSets[charSetIndex], 504 333*time.Millisecond) 505 spinnerErr := cliSpinner.Color("red", "bold") 506 if spinnerErr != nil { 507 logger.WithField("error", spinnerErr).Warn("Failed to set spinner color") 508 } 509 cliSpinnerStarted := false 510 511 // Poll for the current stackID state, and 512 describeStacksInput := &cloudformation.DescribeStacksInput{ 513 StackName: aws.String(stackID), 514 } 515 for waitComplete := false; !waitComplete; { 516 // Startup the spinner if needed... 517 switch logger.Formatter.(type) { 518 case *logrus.JSONFormatter: 519 { 520 logger.Info(pollingMessage) 521 } 522 default: 523 if !cliSpinnerStarted { 524 cliSpinner.Start() 525 defer cliSpinner.Stop() 526 cliSpinnerStarted = true 527 } 528 spinnerText := fmt.Sprintf(" %s (requested: %s)", 529 pollingMessage, 530 humanize.Time(startTime)) 531 cliSpinner.Suffix = spinnerText 532 } 533 534 // Then sleep and figure out if things are done... 535 sleepDuration := time.Duration(11+rand.Int31n(13)) * time.Second 536 time.Sleep(sleepDuration) 537 538 describeStacksOutput, err := awsCloudFormation.DescribeStacks(describeStacksInput) 539 if nil != err { 540 // TODO - add retry iff we're RateExceeded due to collective access 541 return nil, err 542 } 543 if len(describeStacksOutput.Stacks) <= 0 { 544 return nil, fmt.Errorf("failed to enumerate stack info: %v", *describeStacksInput.StackName) 545 } 546 result.stackInfo = describeStacksOutput.Stacks[0] 547 switch *(result.stackInfo).StackStatus { 548 case cloudformation.StackStatusCreateComplete, 549 cloudformation.StackStatusUpdateComplete: 550 result.operationSuccessful = true 551 waitComplete = true 552 case 553 // Include DeleteComplete as new provisions will automatically rollback 554 cloudformation.StackStatusDeleteComplete, 555 cloudformation.StackStatusCreateFailed, 556 cloudformation.StackStatusDeleteFailed, 557 cloudformation.StackStatusRollbackFailed, 558 cloudformation.StackStatusRollbackComplete, 559 cloudformation.StackStatusUpdateRollbackComplete: 560 result.operationSuccessful = false 561 waitComplete = true 562 default: 563 // If this is JSON output, just do the normal thing 564 // NOP 565 } 566 } 567 return result, nil 568 } 569 570 // StableResourceName returns a stable resource name 571 func StableResourceName(value string) string { 572 return CloudFormationResourceName(value, value) 573 } 574 575 // CloudFormationResourceName returns a name suitable as a logical 576 // CloudFormation resource value. See http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/resources-section-structure.html 577 // for more information. The `prefix` value should provide a hint as to the 578 // resource type (eg, `SNSConfigurator`, `ImageTranscoder`). Note that the returned 579 // name is not content-addressable. 580 func CloudFormationResourceName(prefix string, parts ...string) string { 581 hash := sha1.New() 582 _, writeErr := hash.Write([]byte(prefix)) 583 //lint:ignore SA9003 because it's TODO 584 if writeErr != nil { 585 } 586 if len(parts) <= 0 { 587 randValue := rand.Int63() 588 _, writeErr = hash.Write([]byte(strconv.FormatInt(randValue, 10))) 589 //lint:ignore SA9003 because it's TODO 590 if writeErr != nil { 591 } 592 } else { 593 for _, eachPart := range parts { 594 _, writeErr = hash.Write([]byte(eachPart)) 595 //lint:ignore SA9003 because it's TODO 596 if writeErr != nil { 597 } 598 } 599 } 600 resourceName := fmt.Sprintf("%s%s", prefix, hex.EncodeToString(hash.Sum(nil))) 601 602 // Ensure that any non alphanumeric characters are replaced with "" 603 return reCloudFormationInvalidChars.ReplaceAllString(resourceName, "x") 604 } 605 606 // UploadTemplate marshals the given cfTemplate and uploads it to the 607 // supplied bucket using the given KeyName 608 func UploadTemplate(serviceName string, 609 cfTemplate *gocf.Template, 610 s3Bucket string, 611 s3KeyName string, 612 awsSession *session.Session, 613 logger *logrus.Logger) (string, error) { 614 615 logger.WithFields(logrus.Fields{ 616 "Key": s3KeyName, 617 "Bucket": s3Bucket, 618 }).Info("Uploading CloudFormation template") 619 620 s3Uploader := s3manager.NewUploader(awsSession) 621 622 // Serialize the template and upload it 623 cfTemplateJSON, err := json.Marshal(cfTemplate) 624 if err != nil { 625 return "", errors.Wrap(err, "Failed to Marshal CloudFormation template") 626 } 627 628 // Upload the actual CloudFormation template to S3 to maximize the template 629 // size limit 630 // Ref: http://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_CreateStack.html 631 contentBody := string(cfTemplateJSON) 632 uploadInput := &s3manager.UploadInput{ 633 Bucket: &s3Bucket, 634 Key: &s3KeyName, 635 ContentType: aws.String("application/json"), 636 Body: strings.NewReader(contentBody), 637 } 638 templateUploadResult, templateUploadResultErr := s3Uploader.Upload(uploadInput) 639 if nil != templateUploadResultErr { 640 return "", templateUploadResultErr 641 } 642 643 // Be transparent 644 logger.WithFields(logrus.Fields{ 645 "URL": templateUploadResult.Location, 646 }).Info("Template uploaded") 647 return templateUploadResult.Location, nil 648 } 649 650 // StackExists returns whether the given stackName or stackID currently exists 651 func StackExists(stackNameOrID string, awsSession *session.Session, logger *logrus.Logger) (bool, error) { 652 cf := cloudformation.New(awsSession) 653 654 describeStacksInput := &cloudformation.DescribeStacksInput{ 655 StackName: aws.String(stackNameOrID), 656 } 657 describeStacksOutput, err := cf.DescribeStacks(describeStacksInput) 658 logger.WithFields(logrus.Fields{ 659 "DescribeStackOutput": describeStacksOutput, 660 }).Debug("DescribeStackOutput results") 661 662 exists := false 663 if err != nil { 664 logger.WithFields(logrus.Fields{ 665 "DescribeStackOutputError": err, 666 }).Debug("DescribeStackOutput") 667 668 // If the stack doesn't exist, then no worries 669 if strings.Contains(err.Error(), "does not exist") { 670 exists = false 671 } else { 672 return false, err 673 } 674 } else { 675 exists = true 676 } 677 return exists, nil 678 } 679 680 // CreateStackChangeSet returns the DescribeChangeSetOutput 681 // for a given stack transformation 682 func CreateStackChangeSet(changeSetRequestName string, 683 serviceName string, 684 cfTemplate *gocf.Template, 685 templateURL string, 686 awsTags []*cloudformation.Tag, 687 awsCloudFormation *cloudformation.CloudFormation, 688 logger *logrus.Logger) (*cloudformation.DescribeChangeSetOutput, error) { 689 690 capabilities := stackCapabilities(cfTemplate) 691 changeSetInput := &cloudformation.CreateChangeSetInput{ 692 Capabilities: capabilities, 693 ChangeSetName: aws.String(changeSetRequestName), 694 ClientToken: aws.String(changeSetRequestName), 695 Description: aws.String(fmt.Sprintf("Change set for service: %s", serviceName)), 696 StackName: aws.String(serviceName), 697 TemplateURL: aws.String(templateURL), 698 } 699 if len(awsTags) != 0 { 700 changeSetInput.Tags = awsTags 701 } 702 _, changeSetError := awsCloudFormation.CreateChangeSet(changeSetInput) 703 if nil != changeSetError { 704 return nil, changeSetError 705 } 706 707 logger.WithFields(logrus.Fields{ 708 "StackName": serviceName, 709 }).Info("Issued CreateChangeSet request") 710 711 describeChangeSetInput := cloudformation.DescribeChangeSetInput{ 712 ChangeSetName: aws.String(changeSetRequestName), 713 StackName: aws.String(serviceName), 714 } 715 716 var describeChangeSetOutput *cloudformation.DescribeChangeSetOutput 717 718 // Loop, with a total timeout of 3 minutes 719 startTime := time.Now() 720 changeSetStabilized := false 721 for !changeSetStabilized { 722 sleepDuration := cloudformationPollingDelay() 723 time.Sleep(sleepDuration) 724 725 changeSetOutput, describeChangeSetError := awsCloudFormation.DescribeChangeSet(&describeChangeSetInput) 726 727 if nil != describeChangeSetError { 728 return nil, describeChangeSetError 729 } 730 describeChangeSetOutput = changeSetOutput 731 // The current status of the change set, such as CREATE_IN_PROGRESS, CREATE_COMPLETE, 732 // or FAILED. 733 if nil != describeChangeSetOutput { 734 switch *describeChangeSetOutput.Status { 735 case "CREATE_IN_PROGRESS": 736 // If this has taken more than 3 minutes, then that's an error 737 elapsedTime := time.Since(startTime) 738 if elapsedTime > cloudformationPollingTimeout { 739 return nil, fmt.Errorf("failed to finalize ChangeSet within window: %s", elapsedTime.String()) 740 } 741 case "CREATE_COMPLETE": 742 changeSetStabilized = true 743 case "FAILED": 744 return nil, fmt.Errorf("failed to create ChangeSet: %#v", *describeChangeSetOutput) 745 } 746 } 747 } 748 749 logger.WithFields(logrus.Fields{ 750 "DescribeChangeSetOutput": describeChangeSetOutput, 751 }).Debug("DescribeChangeSet result") 752 753 ////////////////////////////////////////////////////////////////////////////// 754 // If there aren't any changes, then skip it... 755 if len(describeChangeSetOutput.Changes) <= 0 { 756 logger.WithFields(logrus.Fields{ 757 "StackName": serviceName, 758 }).Info("No changes detected for service") 759 760 // Delete it... 761 _, deleteChangeSetResultErr := DeleteChangeSet(serviceName, 762 changeSetRequestName, 763 awsCloudFormation) 764 return nil, deleteChangeSetResultErr 765 } 766 return describeChangeSetOutput, nil 767 } 768 769 // DeleteChangeSet is a utility function that attempts to delete 770 // an existing CloudFormation change set, with a bit of retry 771 // logic in case of EC 772 func DeleteChangeSet(stackName string, 773 changeSetRequestName string, 774 awsCloudFormation *cloudformation.CloudFormation) (*cloudformation.DeleteChangeSetOutput, error) { 775 776 // Delete request... 777 deleteChangeSetInput := cloudformation.DeleteChangeSetInput{ 778 ChangeSetName: aws.String(changeSetRequestName), 779 StackName: aws.String(stackName), 780 } 781 782 startTime := time.Now() 783 for { 784 elapsedTime := time.Since(startTime) 785 786 deleteChangeSetResults, deleteChangeSetResultErr := awsCloudFormation.DeleteChangeSet(&deleteChangeSetInput) 787 if nil == deleteChangeSetResultErr { 788 return deleteChangeSetResults, nil 789 } else if strings.Contains(deleteChangeSetResultErr.Error(), "CREATE_IN_PROGRESS") { 790 if elapsedTime > cloudformationPollingTimeout { 791 return nil, fmt.Errorf("failed to delete ChangeSet within timeout window: %s", elapsedTime.String()) 792 } 793 sleepDuration := cloudformationPollingDelay() 794 time.Sleep(sleepDuration) 795 } else { 796 return nil, deleteChangeSetResultErr 797 } 798 } 799 } 800 801 // ListStacks returns a slice of stacks that meet the given filter. 802 func ListStacks(session *session.Session, 803 maxReturned int, 804 stackFilters ...string) ([]*cloudformation.StackSummary, error) { 805 806 listStackInput := &cloudformation.ListStacksInput{ 807 StackStatusFilter: []*string{}, 808 } 809 for _, eachFilter := range stackFilters { 810 listStackInput.StackStatusFilter = append(listStackInput.StackStatusFilter, aws.String(eachFilter)) 811 } 812 cloudformationSvc := cloudformation.New(session) 813 accumulator := []*cloudformation.StackSummary{} 814 for { 815 listResult, listResultErr := cloudformationSvc.ListStacks(listStackInput) 816 if listResultErr != nil { 817 return nil, listResultErr 818 } 819 accumulator = append(accumulator, listResult.StackSummaries...) 820 if len(accumulator) >= maxReturned || listResult.NextToken == nil { 821 return accumulator, nil 822 } 823 listStackInput.NextToken = listResult.NextToken 824 } 825 } 826 827 // ConvergeStackState ensures that the serviceName converges to the template 828 // state defined by cfTemplate. This function establishes a polling loop to determine 829 // when the stack operation has completed. 830 func ConvergeStackState(serviceName string, 831 cfTemplate *gocf.Template, 832 templateURL string, 833 tags map[string]string, 834 startTime time.Time, 835 operationTimeout time.Duration, 836 awsSession *session.Session, 837 outputsDividerChar string, 838 dividerWidth int, 839 logger *logrus.Logger) (*cloudformation.Stack, error) { 840 841 awsCloudFormation := cloudformation.New(awsSession) 842 // Update the tags 843 awsTags := make([]*cloudformation.Tag, 0) 844 if nil != tags { 845 for eachKey, eachValue := range tags { 846 awsTags = append(awsTags, 847 &cloudformation.Tag{ 848 Key: aws.String(eachKey), 849 Value: aws.String(eachValue), 850 }) 851 } 852 } 853 exists, existsErr := StackExists(serviceName, awsSession, logger) 854 if nil != existsErr { 855 return nil, existsErr 856 } 857 stackID := "" 858 if exists { 859 updateErr := updateStackViaChangeSet(serviceName, 860 cfTemplate, 861 templateURL, 862 awsTags, 863 awsCloudFormation, 864 logger) 865 866 if nil != updateErr { 867 return nil, updateErr 868 } 869 stackID = serviceName 870 } else { 871 // Create stack 872 createStackInput := &cloudformation.CreateStackInput{ 873 StackName: aws.String(serviceName), 874 TemplateURL: aws.String(templateURL), 875 TimeoutInMinutes: aws.Int64(int64(operationTimeout.Minutes())), 876 OnFailure: aws.String(cloudformation.OnFailureDelete), 877 Capabilities: stackCapabilities(cfTemplate), 878 } 879 if len(awsTags) != 0 { 880 createStackInput.Tags = awsTags 881 } 882 createStackResponse, createStackResponseErr := awsCloudFormation.CreateStack(createStackInput) 883 if nil != createStackResponseErr { 884 return nil, createStackResponseErr 885 } 886 logger.WithFields(logrus.Fields{ 887 "StackID": *createStackResponse.StackId, 888 }).Info("Creating stack") 889 890 stackID = *createStackResponse.StackId 891 } 892 // Wait for the operation to succeed 893 pollingMessage := "Waiting for CloudFormation operation to complete" 894 convergeResult, convergeErr := WaitForStackOperationComplete(stackID, 895 pollingMessage, 896 awsCloudFormation, 897 logger) 898 if nil != convergeErr { 899 return nil, convergeErr 900 } 901 // Get the events and assemble them into either errors to output 902 // or summary information 903 resourceMetrics := make(map[string]*resourceProvisionMetrics) 904 errorMessages := []string{} 905 events, err := StackEvents(stackID, startTime, awsSession) 906 if nil != err { 907 return nil, fmt.Errorf("failed to retrieve stack events: %s", err.Error()) 908 } 909 910 for _, eachEvent := range events { 911 switch *eachEvent.ResourceStatus { 912 case cloudformation.ResourceStatusCreateFailed, 913 cloudformation.ResourceStatusDeleteFailed, 914 cloudformation.ResourceStatusUpdateFailed: 915 errMsg := fmt.Sprintf("\tError ensuring %s (%s): %s", 916 aws.StringValue(eachEvent.ResourceType), 917 aws.StringValue(eachEvent.LogicalResourceId), 918 aws.StringValue(eachEvent.ResourceStatusReason)) 919 // Only append if the resource failed because something else failed 920 // and this resource was canceled. 921 if !strings.Contains(errMsg, "cancelled") { 922 errorMessages = append(errorMessages, errMsg) 923 } 924 case cloudformation.ResourceStatusCreateInProgress, 925 cloudformation.ResourceStatusUpdateInProgress: 926 existingMetric, existingMetricExists := resourceMetrics[*eachEvent.LogicalResourceId] 927 if !existingMetricExists { 928 existingMetric = &resourceProvisionMetrics{} 929 } 930 existingMetric.resourceType = *eachEvent.ResourceType 931 existingMetric.logicalResourceID = *eachEvent.LogicalResourceId 932 existingMetric.startTime = *eachEvent.Timestamp 933 resourceMetrics[*eachEvent.LogicalResourceId] = existingMetric 934 case cloudformation.ResourceStatusCreateComplete, 935 cloudformation.ResourceStatusUpdateComplete: 936 existingMetric, existingMetricExists := resourceMetrics[*eachEvent.LogicalResourceId] 937 if !existingMetricExists { 938 existingMetric = &resourceProvisionMetrics{} 939 } 940 existingMetric.logicalResourceID = *eachEvent.LogicalResourceId 941 existingMetric.endTime = *eachEvent.Timestamp 942 resourceMetrics[*eachEvent.LogicalResourceId] = existingMetric 943 default: 944 // NOP 945 } 946 } 947 948 // If it didn't work, then output some failure information 949 if !convergeResult.operationSuccessful { 950 logger.Error("Stack provisioning error") 951 for _, eachError := range errorMessages { 952 logger.Error(eachError) 953 } 954 return nil, fmt.Errorf("failed to provision: %s", serviceName) 955 } 956 957 // Rip through the events so that we can output exactly how long it took to 958 // update each resource 959 resourceStats := make([]*resourceProvisionMetrics, len(resourceMetrics)) 960 resourceStatIndex := 0 961 for _, eachResource := range resourceMetrics { 962 eachResource.elapsed = eachResource.endTime.Sub(eachResource.startTime) 963 resourceStats[resourceStatIndex] = eachResource 964 resourceStatIndex++ 965 } 966 // Create a slice with them all, sorted by total elapsed mutation time 967 sort.Slice(resourceStats, func(i, j int) bool { 968 return resourceStats[i].elapsed > resourceStats[j].elapsed 969 }) 970 971 // Output the sorted time it took to create the necessary resources... 972 outputHeader := "CloudFormation Metrics " 973 suffix := strings.Repeat(outputsDividerChar, dividerWidth-len(outputHeader)) 974 logger.Info(fmt.Sprintf("%s%s", outputHeader, suffix)) 975 for _, eachResourceStat := range resourceStats { 976 logger.WithFields(logrus.Fields{ 977 "Resource": eachResourceStat.logicalResourceID, 978 "Type": eachResourceStat.resourceType, 979 "Duration": fmt.Sprintf("%.2fs", eachResourceStat.elapsed.Seconds()), 980 }).Info(" Operation duration") 981 } 982 983 if nil != convergeResult.stackInfo.Outputs { 984 // Add a nice divider if there are Stack specific output 985 outputHeader := "Stack Outputs " 986 suffix := strings.Repeat(outputsDividerChar, dividerWidth-len(outputHeader)) 987 logger.Info(fmt.Sprintf("%s%s", outputHeader, suffix)) 988 989 for _, eachOutput := range convergeResult.stackInfo.Outputs { 990 logger.WithFields(logrus.Fields{ 991 "Value": aws.StringValue(eachOutput.OutputValue), 992 "Description": aws.StringValue(eachOutput.Description), 993 }).Info(fmt.Sprintf(" %s", aws.StringValue(eachOutput.OutputKey))) 994 } 995 } 996 return convergeResult.stackInfo, nil 997 } 998 999 // UserAccountScopedStackName returns a CloudFormation stack 1000 // name that takes into account the current username that is 1001 //associated with the supplied AWS credentials 1002 /* 1003 A stack name can contain only alphanumeric characters 1004 (case sensitive) and hyphens. It must start with an alphabetic 1005 \character and cannot be longer than 128 characters. 1006 */ 1007 func UserAccountScopedStackName(basename string, 1008 awsSession *session.Session) (string, error) { 1009 awsName, awsNameErr := platformAccountUserName(awsSession) 1010 if awsNameErr != nil { 1011 return "", awsNameErr 1012 } 1013 userName := strings.Replace(awsName, " ", "-", -1) 1014 userName = strings.Replace(userName, ".", "-", -1) 1015 return fmt.Sprintf("%s-%s", basename, userName), nil 1016 } 1017 1018 // UserScopedStackName returns a CloudFormation stack 1019 // name that takes into account the current username 1020 /* 1021 A stack name can contain only alphanumeric characters 1022 (case sensitive) and hyphens. It must start with an alphabetic 1023 \character and cannot be longer than 128 characters. 1024 */ 1025 func UserScopedStackName(basename string) string { 1026 platformUserName := platformUserName() 1027 if platformUserName == "" { 1028 return basename 1029 } 1030 userName := strings.Replace(platformUserName, " ", "-", -1) 1031 userName = strings.Replace(userName, ".", "-", -1) 1032 return fmt.Sprintf("%s-%s", basename, userName) 1033 }