// Forecast looks at your account and tries to predict things that will // go wrong when you attempt to CREATE, UPDATE, or DELETE a stack package forecast import ( "fmt" "io" "os" "path/filepath" "github.com/aws-cloudformation/rain/cft" "github.com/aws-cloudformation/rain/cft/parse" "github.com/aws-cloudformation/rain/internal/aws/cfn" "github.com/aws-cloudformation/rain/internal/cmd/deploy" "github.com/aws-cloudformation/rain/internal/config" "github.com/aws-cloudformation/rain/internal/console/spinner" "github.com/aws-cloudformation/rain/internal/dc" "github.com/aws-cloudformation/rain/internal/s11n" "github.com/aws-cloudformation/rain/internal/ui" "github.com/spf13/cobra" "gopkg.in/yaml.v3" "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" ) // The role name to use for the IAM policy simulator (optional --role) var RoleArn string // This is an experimental feature that might break between minor releases var Experimental bool // The resource type to check (optional --type to limit checks to one type) var ResourceType string // If true, don't perform permissions checks to save time var SkipIAM bool // The optional parameters to use to create a change set for update predictions (--params) var params []string // The optional tags to use to create a change set for update predictions (--tags) var tags []string // The optional path to a file that contains params (--config) var configFilePath string // Input to forecast prediction functions type PredictionInput struct { source cft.Template stackName string resource *yaml.Node logicalId string stackExists bool stack types.Stack typeName string dc *dc.DeployConfig } // The current line number in the template var LineNumber int // Forecast represents predictions for a single resource in the template type Forecast struct { TypeName string LogicalId string Passed []string Failed []string } func (f *Forecast) GetNumChecked() int { return len(f.Passed) + len(f.Failed) } func (f *Forecast) GetNumFailed() int { return len(f.Failed) } func (f *Forecast) GetNumPassed() int { return len(f.Passed) } func (f *Forecast) Append(forecast Forecast) { f.Failed = append(f.Failed, forecast.Failed...) f.Passed = append(f.Passed, forecast.Passed...) } // Add adds a pass or fail message, formatting it to include the type name and logical id func (f *Forecast) Add(passed bool, message string) { msg := fmt.Sprintf("%v: %v %v - %v", LineNumber, f.TypeName, f.LogicalId, message) if passed { f.Passed = append(f.Passed, msg) } else { f.Failed = append(f.Failed, msg) } // TODO - Do we want each failure to have a code so that it can be ignored? } func makeForecast(typeName string, logicalId string) Forecast { return Forecast{ TypeName: typeName, LogicalId: logicalId, Passed: make([]string, 0), Failed: make([]string, 0), } } // forecasters is a map of resource type names to prediction functions. var forecasters = make(map[string]func(input PredictionInput) Forecast) // Push a message about checking a resource onto the spinner func spin(typeName string, logicalId string, message string) { spinner.Push(fmt.Sprintf("%v %v - %v", typeName, logicalId, message)) } // Run all forecasters for the type func forecastForType(input PredictionInput) Forecast { forecast := makeForecast(input.typeName, input.logicalId) // Only run the forecaster if it matches the optional --type arg, // or if that arg was not provided. if ResourceType != "" && ResourceType != input.typeName { config.Debugf("Not running forecasters for %v", input.typeName) return forecast } // Call generic prediction functions that we can run against // all resources, even if there is not a predictor. spin(input.typeName, input.logicalId, "exists already?") // Make sure the resource does not already exist if cfn.ResourceAlreadyExists(input.typeName, input.resource, input.stackExists, input.source.Node, input.dc) { forecast.Add(false, "Already exists") } else { forecast.Add(true, "Does not exist") } // Below are some ideas for things we might be able to check in a generic way // Check permissions // (see S3 example, we would need to figure out the arn for each service) // TODO - Not sure if this is practical in a generic way // Check service quotas // TODO - Can we do this in a generic way? // https://docs.aws.amazon.com/sdk-for-go/api/service/servicequotas/#ServiceQuotas.GetServiceQuota // https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/servicequotas // TODO - What about drift errors? Can we predict what will fail based on // a drift detection report for the stack if it already exists? // TODO - Regional capabilities. Does this service/feature exist in the region? // See if we have a specific forecaster for this type fn, found := forecasters[input.typeName] if found { // Call the prediction function and append the results forecast.Append(fn(input)) } spinner.Pop() return forecast } // Query the account to make predictions about deployment failures. // Returns true if no failures are predicted. func predict(source cft.Template, stackName string, stack types.Stack, stackExists bool, dc *dc.DeployConfig) bool { config.Debugf("About to make API calls for failure prediction...") // Visit each resource in the template and see if it matches // one of our predictions // TODO: Create a changeset to evaluate updates forecast := makeForecast("", "") rootMap := source.Node.Content[0] // Add the --debug arg to see a json version of the yaml node data model for the template //config.Debugf("node: %v", toJson(rootMap)) // Iterate over each resource _, resources := s11n.GetMapValue(rootMap, "Resources") if resources == nil { panic("Expected to find a Resources section in the template") } for i, r := range resources.Content { if i%2 != 0 { continue } logicalId := r.Value LineNumber = r.Line config.Debugf("logicalId: %v", logicalId) resource := resources.Content[i+1] _, typeNode := s11n.GetMapValue(resource, "Type") if typeNode == nil { panic(fmt.Sprintf("Expected %v to have a Type", logicalId)) } // Check the type and call functions that make checks // on that type of resource. typeName := typeNode.Value // Should be something like AWS::S3::Bucket config.Debugf("typeName: %v", typeName) input := PredictionInput{} input.logicalId = logicalId input.source = source input.resource = resource input.stackName = stackName input.stackExists = stackExists input.stack = stack input.typeName = typeName input.dc = dc forecast.Append(forecastForType(input)) } spinner.Stop() if forecast.GetNumFailed() > 0 { fmt.Println("Stormy weather ahead! đŸŒĒ") // đŸŒŠī¸â›ˆ fmt.Println(forecast.GetNumFailed(), "checks failed out of", forecast.GetNumChecked(), "total checks") for _, reason := range forecast.Failed { fmt.Println() fmt.Println(reason) } return false } else { fmt.Println("Clear skies! 🌤 All", forecast.GetNumChecked(), "checks passed.") return true } // TODO - We might be able to incorporate AWS Config proactive controls here // https://aws.amazon.com/blogs/aws/new-aws-config-rules-now-support-proactive-compliance/ // What about hooks? Could we invoke those handlers to see if they will fail before deployment? } // Cmd is the forecast command's entrypoint var Cmd = &cobra.Command{ Use: "forecast --experimental