Repository: aws-cloudformation/rain
Pull request #389 (2024-05-17 17:58:30)

v1.9.0
Leaning into `rain forecast` in this release. Made it possible to suppress warnings, and added a number of new checks related to auto scaling groups.

Also added a mode to the interactive rain build command to ask bedrock arbitrary questions and include a file for reference.



Patch:
diff --git a/cft/cft.go b/cft/cft.go
index 09293cac..dba43c7f 100644
--- a/cft/cft.go
+++ b/cft/cft.go
@@ -147,3 +147,22 @@ func (t Template) GetTypes() ([]string, error) {
 
 	return retval, nil
 }
+
+func (t Template) GetResourcesOfType(typeName string) []*yaml.Node {
+	resources, err := t.GetSection(Resources)
+	if err != nil {
+		return nil
+	}
+	retval := make([]*yaml.Node, 0)
+	for i := 0; i < len(resources.Content); i += 2 {
+		resource := resources.Content[i+1]
+		_, typ, _ := s11n.GetMapValue(resource, "Type")
+		if typ == nil {
+			continue
+		}
+		if typ.Value == typeName {
+			retval = append(retval, resource)
+		}
+	}
+	return retval
+}
diff --git a/go.mod b/go.mod
index f8b6a486..20759760 100644
--- a/go.mod
+++ b/go.mod
@@ -10,7 +10,7 @@ require (
 	github.com/aws/aws-sdk-go-v2 v1.27.0
 	github.com/aws/aws-sdk-go-v2/config v1.27.11
 	github.com/aws/aws-sdk-go-v2/credentials v1.17.15
-	github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.8.0
+	github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.8.3
 	github.com/aws/aws-sdk-go-v2/service/cloudformation v1.50.1
 	github.com/aws/aws-sdk-go-v2/service/ec2 v1.161.3
 	github.com/aws/aws-sdk-go-v2/service/s3 v1.54.2
@@ -30,9 +30,12 @@ require (
 
 require (
 	github.com/apple/pkl-go v0.6.1-0.20240320183646-5827572b8112
+	github.com/aws/aws-sdk-go-v2/service/acm v1.25.5
+	github.com/aws/aws-sdk-go-v2/service/kms v1.31.3
 	github.com/aws/aws-sdk-go-v2/service/lightsail v1.37.3
 	github.com/aws/aws-sdk-go-v2/service/rds v1.78.1
 	github.com/aws/aws-sdk-go-v2/service/servicequotas v1.21.4
+	github.com/aws/aws-sdk-go-v2/service/ssm v1.50.1
 	github.com/fatih/color v1.16.0
 	github.com/manifoldco/promptui v0.9.0
 	github.com/mattn/go-runewidth v0.0.15
diff --git a/go.sum b/go.sum
index 873d7dbf..f11430dc 100644
--- a/go.sum
+++ b/go.sum
@@ -20,8 +20,12 @@ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7
 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
 github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.7 h1:/FUtT3xsoHO3cfh+I/kCbcMCN98QZRsiFet/V8QkWSs=
 github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.7/go.mod h1:MaCAgWpGooQoCWZnMur97rGn5dp350w2+CeiV5406wE=
+github.com/aws/aws-sdk-go-v2/service/acm v1.25.5 h1:VUFUI8yF8Jgv6DtjS3eBcIsWrZzOsQ9qNzqEh8EhYEY=
+github.com/aws/aws-sdk-go-v2/service/acm v1.25.5/go.mod h1:kTFYiaoqqRsZC+BYdciI5tFLtuodontKG5jGjCGtPUg=
 github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.8.0 h1:kt4JDYAIjygWfuBPMtmjgp2Dnd1HckQGJ5pnS6Q7eLY=
 github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.8.0/go.mod h1:nZspkhg+9p8iApLFoyAqfyuMP0F38acy2Hm3r5r95Cg=
+github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.8.3 h1:Fihjyd6DeNjcawBEGLH9dkIEUi6AdhucDKPE9nJ4QiY=
+github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.8.3/go.mod h1:opvUj3ismqSCxYc+m4WIjPL0ewZGtvp0ess7cKvBPOQ=
 github.com/aws/aws-sdk-go-v2/service/cloudcontrol v1.18.5 h1:8s0iOBAAUEq0m51PWmaCuEfrdH0OQkXyGL4+jgQoIJ8=
 github.com/aws/aws-sdk-go-v2/service/cloudcontrol v1.18.5/go.mod h1:oOvzqGwjzl5fyWi0C7YfOalzMDS8R4yapREwUVV5gBY=
 github.com/aws/aws-sdk-go-v2/service/cloudformation v1.50.1 h1:lFyHV8TCw4GRj39hvnGzENfTEFPPUpRwC8cqaAPp8dk=
@@ -38,6 +42,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.9 h1:Wx0rlZoEJ
 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.9/go.mod h1:aVMHdE0aHO3v+f/iw01fmXV/5DbfQ3Bi9nN7nd9bE9Y=
 github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.7 h1:uO5XR6QGBcmPyo2gxofYJLFkcVQ4izOoGDNenlZhTEk=
 github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.7/go.mod h1:feeeAYfAcwTReM6vbwjEyDmiGho+YgBhaFULuXDW8kc=
+github.com/aws/aws-sdk-go-v2/service/kms v1.31.3 h1:wLBgq6nDNYdd0A5CvscVAKV5SVlHKOHVPedpgtigATg=
+github.com/aws/aws-sdk-go-v2/service/kms v1.31.3/go.mod h1:8lETO9lelSG2B6KMXFh2OwPPqGV6WQM3RqLAEjP1xaU=
 github.com/aws/aws-sdk-go-v2/service/lightsail v1.37.3 h1:ClOI0GZ62/I3MfSK9GO7dnqCLxz0QiMQOPRX+f+zvEs=
 github.com/aws/aws-sdk-go-v2/service/lightsail v1.37.3/go.mod h1:KHTaLdvivCfFDAE8jed5OogP1l+GYhrsaLTOV3honIQ=
 github.com/aws/aws-sdk-go-v2/service/rds v1.78.1 h1:D3XX2O6IzStNWEK2GU5EQTZVZ2r2Q4aRhWplndGIQR4=
@@ -46,6 +52,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.54.2 h1:gYSJhNiOF6J9xaYxu2NFNstoiNELw
 github.com/aws/aws-sdk-go-v2/service/s3 v1.54.2/go.mod h1:739CllldowZiPPsDFcJHNF4FXrVxaSGVnZ9Ez9Iz9hc=
 github.com/aws/aws-sdk-go-v2/service/servicequotas v1.21.4 h1:SSDkZRAO8Ok5SoQ4BJ0onDeb0ga8JBOCkUmNEpRChcw=
 github.com/aws/aws-sdk-go-v2/service/servicequotas v1.21.4/go.mod h1:plXue/Zg49kU3uU6WwfCWgRR5SRINNiJf03Y/UhYOhU=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.50.1 h1:vgpeoBRWw22qcb1xo3eJFkuulwPI4E/xQgIGi0gtVUs=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.50.1/go.mod h1:Ebk/HZmGhxWKDVxM4+pwbxGjm3RQOQLMjAEosI3ss9Q=
 github.com/aws/aws-sdk-go-v2/service/sso v1.20.8 h1:Kv1hwNG6jHC/sxMTe5saMjH6t6ZLkgfvVxyEjfWL1ks=
 github.com/aws/aws-sdk-go-v2/service/sso v1.20.8/go.mod h1:c1qtZUWtygI6ZdvKppzCSXsDOq5I4luJPZ0Ud3juFCA=
 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.2 h1:nWBZ1xHCF+A7vv9sDzJOq4NWIdzFYm0kH7Pr4OjHYsQ=
diff --git a/internal/aws/acm/acm.go b/internal/aws/acm/acm.go
new file mode 100644
index 00000000..06676733
--- /dev/null
+++ b/internal/aws/acm/acm.go
@@ -0,0 +1,39 @@
+package acm
+
+import (
+	"context"
+	rainaws "github.com/aws-cloudformation/rain/internal/aws"
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/aws/aws-sdk-go-v2/service/acm"
+	"time"
+)
+
+func getClient() *acm.Client {
+	return acm.NewFromConfig(rainaws.Config())
+}
+
+// CheckCertificate checks if the certificate exists and is valid
+func CheckCertificate(arn string) (bool, error) {
+	client := getClient()
+	_, err := client.GetCertificate(context.Background(), &acm.GetCertificateInput{
+		CertificateArn: &arn,
+	})
+
+	if err != nil {
+		return false, err
+	}
+
+	res, err := client.DescribeCertificate(context.Background(), &acm.DescribeCertificateInput{
+		CertificateArn: &arn,
+	})
+	if err != nil {
+		return false, err
+	}
+	// Make sure the cert has not expired
+	if res.Certificate.NotAfter.Before(time.Now()) {
+		config.Debugf("Cert expired: %s", arn)
+		return false, nil
+	}
+
+	return true, nil
+}
diff --git a/internal/aws/ec2/ec2.go b/internal/aws/ec2/ec2.go
index e1183afe..4bab00ae 100644
--- a/internal/aws/ec2/ec2.go
+++ b/internal/aws/ec2/ec2.go
@@ -4,14 +4,18 @@ package ec2
 
 import (
 	"context"
+	"fmt"
 	"sort"
 
-	"github.com/aws-cloudformation/rain/internal/aws"
+	rainaws "github.com/aws-cloudformation/rain/internal/aws"
+	"github.com/aws/aws-sdk-go-v2/aws"
+	"github.com/aws/aws-sdk-go-v2/service/ec2/types"
+
 	"github.com/aws/aws-sdk-go-v2/service/ec2"
 )
 
 func getClient() *ec2.Client {
-	return ec2.NewFromConfig(aws.Config())
+	return ec2.NewFromConfig(rainaws.Config())
 }
 
 // GetRegions returns all region names as strings
@@ -42,3 +46,79 @@ func CheckKeyPairExists(name string) (bool, error) {
 
 	return len(res.KeyPairs) > 0, nil
 }
+
+func GetInstanceType(instanceType string) (*types.InstanceTypeInfo, error) {
+	res, err := getClient().DescribeInstanceTypes(context.Background(),
+		&ec2.DescribeInstanceTypesInput{
+			InstanceTypes: []types.InstanceType{types.InstanceType(instanceType)},
+		})
+
+	if err != nil {
+		return nil, err
+	}
+
+	if len(res.InstanceTypes) == 0 {
+		return nil, fmt.Errorf("no instance type found for %s", instanceType)
+	}
+
+	return &res.InstanceTypes[0], nil
+}
+
+func GetImage(imageID string) (*types.Image, error) {
+	res, err := getClient().DescribeImages(context.Background(),
+		&ec2.DescribeImagesInput{
+			ImageIds: []string{imageID},
+		})
+
+	if err != nil {
+		return nil, err
+	}
+
+	if len(res.Images) == 0 {
+		return nil, fmt.Errorf("no image found for %s", imageID)
+	}
+
+	return &res.Images[0], nil
+}
+
+func GetInstanceTypesForArchitecture(architecture string) ([]string, error) {
+	res, err := getClient().DescribeInstanceTypes(context.Background(),
+		&ec2.DescribeInstanceTypesInput{
+			Filters: []types.Filter{
+				{
+					Name:   aws.String("processor-info.supported-architecture"),
+					Values: []string{architecture},
+				},
+			},
+		})
+
+	if err != nil {
+		return nil, err
+	}
+
+	retval := make([]string, len(res.InstanceTypes))
+	for i, instanceType := range res.InstanceTypes {
+		retval[i] = string(instanceType.InstanceType)
+	}
+	return retval, nil
+}
+
+// GetDefaultVPCId returns the default VPC Id, or a blank string
+func GetDefaultVPCId() (string, error) {
+
+	output, err := getClient().DescribeVpcs(context.Background(), &ec2.DescribeVpcsInput{})
+	if err != nil {
+		return "", err
+	}
+
+	// Find the default VPC
+	var defaultVpcID string
+	for _, vpc := range output.Vpcs {
+		if *vpc.IsDefault {
+			defaultVpcID = *vpc.VpcId
+			break
+		}
+	}
+
+	return defaultVpcID, nil
+}
diff --git a/internal/aws/ec2/mock.go b/internal/aws/ec2/mock.go
index b4be35a9..87b295cf 100644
--- a/internal/aws/ec2/mock.go
+++ b/internal/aws/ec2/mock.go
@@ -2,6 +2,8 @@
 
 package ec2
 
+import "github.com/aws/aws-sdk-go-v2/service/ec2/types"
+
 // GetRegions returns all region names as strings
 func GetRegions() ([]string, error) {
 	return []string{
@@ -14,3 +16,19 @@ func GetRegions() ([]string, error) {
 func CheckKeyPairExists(name string) (bool, error) {
 	return true, nil
 }
+
+func GetImage(imageID string) (*types.Image, error) {
+	return nil, nil
+}
+
+func GetInstanceType(instanceType string) (*types.InstanceTypeInfo, error) {
+	return nil, nil
+}
+
+func GetInstanceTypesForArchitecture(architecture string) ([]string, error) {
+	return nil, nil
+}
+
+func GetDefaultVPCId() (string, error) {
+	return "", nil
+}
diff --git a/internal/aws/iam/iam.go b/internal/aws/iam/iam.go
index e7da4ed6..401bff0b 100644
--- a/internal/aws/iam/iam.go
+++ b/internal/aws/iam/iam.go
@@ -2,11 +2,13 @@ package iam
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
+	"net/url"
 	"regexp"
 	"strings"
 
-	aws "github.com/aws-cloudformation/rain/internal/aws"
+	"github.com/aws-cloudformation/rain/internal/aws"
 	"github.com/aws-cloudformation/rain/internal/config"
 	"github.com/aws-cloudformation/rain/internal/console/spinner"
 	"github.com/aws-cloudformation/rain/internal/s11n"
@@ -21,7 +23,7 @@ func getClient() *iam.Client {
 	return iam.NewFromConfig(aws.Config())
 }
 
-// Get the role arn of the caller based on the aws config
+// GetCallerArn gets the role arn of the caller based on the aws config
 func GetCallerArn(config awsgo.Config) (string, error) {
 	stsClient := sts.NewFromConfig(config)
 	stsRes, stsErr := stsClient.GetCallerIdentity(context.Background(),
@@ -48,8 +50,8 @@ func TransformCallerArn(stsResArn string) string {
 // Will this work consistently for other SSO providers?
 // Is there a programmatic way to retrieve the actual role?
 func convertAssumeRoleToRole(stsResArn string) string {
-	sts := strings.Split(stsResArn, "sts::")[1]
-	accountId := strings.Split(sts, ":")[0]
+	stsStr := strings.Split(stsResArn, "sts::")[1]
+	accountId := strings.Split(stsStr, ":")[0]
 	assumedRole := strings.Split(stsResArn, "assumed-role/")[1]
 	actualRoleName := strings.Split(assumedRole, "/")[0]
 	return fmt.Sprintf("arn:aws:iam::%v:role/%v", accountId, actualRoleName)
@@ -129,13 +131,23 @@ func Simulate(
 	return allowed, messages
 }
 
-// Check to see if a role exists in the account
-func RoleExists(roleArn string) bool {
+func GetRoleNameFromArn(roleArn string) (string, error) {
 	tokens := strings.Split(roleArn, ":role/")
-	lastToken := tokens[len(tokens)-1]
-	config.Debugf("RoleExists %v %v", roleArn, lastToken)
+	if len(tokens) != 2 {
+		return "", fmt.Errorf("invalid role arn: %v", roleArn)
+	}
+	return tokens[1], nil
+}
+
+// RoleExists checks to see if a role exists in the account
+func RoleExists(roleArn string) bool {
+	roleName, err := GetRoleNameFromArn(roleArn)
+	if err != nil {
+		config.Debugf("RoleExists GetRoleNameFromArn Error for %v: %v", roleArn, err)
+		return false
+	}
 	res, err := getClient().GetRole(context.Background(), &iam.GetRoleInput{
-		RoleName: &lastToken,
+		RoleName: &roleName,
 	})
 	if err != nil {
 		config.Debugf("RoleExists GetRole Error for %v: %v", roleArn, err)
@@ -145,7 +157,7 @@ func RoleExists(roleArn string) bool {
 	return true
 }
 
-// Check to see if the principal exists in the account
+// PrincipalExists checks to see if the principal exists in the account
 func PrincipalExists(principal string) (bool, error) {
 
 	config.Debugf("PrincipalExists %v", principal)
@@ -189,7 +201,7 @@ func PrincipalExists(principal string) (bool, error) {
 	return false, nil
 }
 
-// Check a PolicyDocument to make sure it will not result in failures
+// CheckPolicyDocument checks a PolicyDocument to make sure it will not result in failures
 func CheckPolicyDocument(doc *yaml.Node) (bool, error) {
 
 	policyOk := true
@@ -205,9 +217,7 @@ func CheckPolicyDocument(doc *yaml.Node) (bool, error) {
 							config.Debugf("About to check if principal exists: %v", p)
 							if p.Kind == yaml.MappingNode {
 								config.Debugf("principal is a map")
-
-								// This is.. hard. We need to resolve !Sub, !Ref, etc
-								// TODO
+								// TODO - resolve intrinsics if we can
 							} else {
 								exists, err := PrincipalExists(p.Value)
 								if err != nil || !exists {
@@ -224,3 +234,51 @@ func CheckPolicyDocument(doc *yaml.Node) (bool, error) {
 
 	return policyOk, nil
 }
+
+// CanAssumeRole checks if a service can assume a role
+func CanAssumeRole(roleArn string, serviceName string) (bool, error) {
+	roleName, err := GetRoleNameFromArn(roleArn)
+	if err != nil {
+		config.Debugf("CanAssumeRole GetRoleNameFromArn Error for %v: %v", roleArn, err)
+		return false, err
+	}
+
+	config.Debugf("CanAssumeRole checking role %v for service %v", roleName, serviceName)
+
+	// Get the role details
+	roleOutput, err := getClient().GetRole(context.Background(),
+		&iam.GetRoleInput{
+			RoleName: awsgo.String(roleName),
+		})
+
+	if err != nil {
+		config.Debugf("CanAssumeRole Error: %v", err)
+		return false, err
+	}
+
+	// Parse the AssumeRolePolicyDocument
+	policyDoc := roleOutput.Role.AssumeRolePolicyDocument
+	// Decode the policy document
+	decodedDocument, _ := url.PathUnescape(*policyDoc)
+	config.Debugf("CanAssumeRole policyDoc: %v", decodedDocument)
+	var policy struct {
+		Statement []struct {
+			Principal struct {
+				Service string `json:"Service"`
+			} `json:"Principal"`
+		} `json:"Statement"`
+	}
+	if err := json.Unmarshal([]byte(decodedDocument), &policy); err != nil {
+		config.Debugf("Unable to parse policy: %v", err)
+		return false, err
+	}
+
+	// Check if the service is allowed to assume the role
+	for _, stmt := range policy.Statement {
+		if stmt.Principal.Service == serviceName {
+			return true, nil
+		}
+	}
+
+	return false, nil
+}
diff --git a/internal/aws/kms/kms.go b/internal/aws/kms/kms.go
new file mode 100644
index 00000000..e67fe978
--- /dev/null
+++ b/internal/aws/kms/kms.go
@@ -0,0 +1,33 @@
+package kms
+
+import (
+	"context"
+	rainaws "github.com/aws-cloudformation/rain/internal/aws"
+	"github.com/aws/aws-sdk-go-v2/service/kms"
+	"time"
+)
+
+func getClient() *kms.Client {
+	return kms.NewFromConfig(rainaws.Config())
+}
+
+func IsKeyArnValid(keyArn string) bool {
+	client := getClient()
+
+	// Use the aws go sdk to call kms and make sure the key is valid
+	key, err := client.DescribeKey(context.Background(), &kms.DescribeKeyInput{KeyId: &keyArn})
+	if err != nil {
+		// Throws an error if it can't find the key
+		return false
+	}
+	if !key.KeyMetadata.Enabled {
+		return false
+	}
+	if key.KeyMetadata.ValidTo != nil {
+		// Make sure the key is not expired
+		if key.KeyMetadata.ValidTo.Before(time.Now()) {
+			return false
+		}
+	}
+	return true
+}
diff --git a/internal/aws/rds/rds.go b/internal/aws/rds/rds.go
index e45cd85c..e8dc5632 100644
--- a/internal/aws/rds/rds.go
+++ b/internal/aws/rds/rds.go
@@ -22,6 +22,10 @@ func GetValidEngineVersions(engine string) ([]string, error) {
 	}
 
 	for _, v := range res.DBEngineVersions {
+		if v.EngineVersion == nil {
+			// This should never happen
+			continue
+		}
 		retval = append(retval, *v.EngineVersion)
 	}
 
diff --git a/internal/aws/ssm/ssm.go b/internal/aws/ssm/ssm.go
new file mode 100644
index 00000000..b82f785f
--- /dev/null
+++ b/internal/aws/ssm/ssm.go
@@ -0,0 +1,24 @@
+package ssm
+
+import (
+	"context"
+	rainaws "github.com/aws-cloudformation/rain/internal/aws"
+	"github.com/aws/aws-sdk-go-v2/service/ssm"
+)
+
+func getClient() *ssm.Client {
+	return ssm.NewFromConfig(rainaws.Config())
+}
+
+// GetParameter returns the value of the specified parameter.
+func GetParameter(name string) (string, error) {
+	client := getClient()
+	parameter, err := client.GetParameter(context.Background(), &ssm.GetParameterInput{
+		Name: &name,
+	})
+	if err != nil {
+		return "", err
+	}
+
+	return *parameter.Parameter.Value, nil
+}
diff --git a/internal/cmd/build/interactive.go b/internal/cmd/build/interactive.go
index 995f70b7..b2beb509 100644
--- a/internal/cmd/build/interactive.go
+++ b/internal/cmd/build/interactive.go
@@ -1,6 +1,8 @@
 package build
 
 import (
+	"fmt"
+	"os"
 	"strings"
 
 	"github.com/aws-cloudformation/rain/internal/aws/cfn"
@@ -148,6 +150,56 @@ func policy() {
 	}
 }
 
+func generic() {
+	// Choose the model
+	selections := []buildSelection{
+		{Name: HAIKU, Text: "Claude 3 Haiku"},
+		{Name: SONNET, Text: "Claude 3 Sonnet"},
+		{Name: OPUS, Text: "Claude 3 Opus"},
+	}
+	model = openPrompt("Choose a model", selections)
+
+	// Ask if the user wants to include a file
+	selections = []buildSelection{
+		{Name: YES, Text: "Yes"},
+		{Name: NO, Text: "No"},
+	}
+	yesno := openPrompt("Do you want to include the contents of a file with your question?", selections)
+
+	// Get the file content
+	var content string
+	if yesno == YES {
+		prompt := promptui.Prompt{
+			Label: "Enter the file path: ",
+		}
+		path, err := prompt.Run()
+		if err != nil {
+			panic(err)
+		}
+		fmt.Println("Including contents of file:", path)
+		contentBytes, err := os.ReadFile(path)
+		if err != nil {
+			panic(err)
+		}
+		content = string(contentBytes)
+	}
+
+	// Get the prompt
+	prompt := promptui.Prompt{
+		Label: "Prompt: ",
+	}
+	p, err := prompt.Run()
+	if err != nil {
+		panic(err)
+	}
+
+	if content != "" {
+		p += "\n\nReference the file contents below this line.\n\n" + content
+	}
+
+	promptGeneric(p, modelId(model))
+}
+
 // openPrompt uses promptui to show selections and returns what was selected
 func openPrompt(label string, selections []buildSelection) string {
 
@@ -201,6 +253,7 @@ func interactive() {
 		{Name: SCHEMA, Text: "Output the schema for a resource type"},
 		{Name: TEMPLATE, Text: "Create a CloudFormation template"},
 		{Name: POLICY, Text: "Create a policy validation file"},
+		{Name: GENERIC, Text: "Ask a bedrock model a generic question"},
 	}
 
 	label := "Entering build interactive mode... what would you like to do?"
@@ -219,6 +272,9 @@ func interactive() {
 	case POLICY:
 		policy()
 		return
+	case GENERIC:
+		generic()
+		return
 	}
 
 }
@@ -243,4 +299,7 @@ const (
 	HAIKU     = "claude3haiku"
 	GUARD     = "guard"
 	OPA       = "opa"
+	GENERIC   = "generic"
+	YES       = "yes"
+	NO        = "no"
 )
diff --git a/internal/cmd/build/prompt.go b/internal/cmd/build/prompt.go
index 5baa9e4d..90df20b5 100644
--- a/internal/cmd/build/prompt.go
+++ b/internal/cmd/build/prompt.go
@@ -169,3 +169,26 @@ func promptCfn(p string, mid string) {
 
 	}
 }
+
+func promptGeneric(p string, mid string) {
+
+	if isClaude2() {
+		config.Debugf("About to invoke bedrock %s with prompt: %s", mid, p)
+		r, err := bedrock.Invoke(p)
+		if err != nil {
+			panic(err)
+		}
+
+		fmt.Println(r)
+	} else {
+		system := "The conversation is likely to be about Amazon Web Services and Infrastructure as Code, but may be on any subject."
+		config.Debugf("About to invoke bedrock %s with system: %s, prompt: %s", mid, system, p)
+		r, err := bedrock.InvokeClaude3(p, mid, system)
+		if err != nil {
+			panic(err)
+		}
+
+		fmt.Println(r)
+
+	}
+}
diff --git a/internal/cmd/build/recommend.go b/internal/cmd/build/recommend.go
index baa6992d..fe6dc6d0 100644
--- a/internal/cmd/build/recommend.go
+++ b/internal/cmd/build/recommend.go
@@ -115,6 +115,11 @@ func recommend(args []string) {
 				{Name: "vpc", Text: "A VPC with 2 private and 2 public subnets"},
 			},
 		},
+		{Name: "ec2", Text: "EC2 Instances",
+			Sub: []reco{
+				{Name: "autoscaling", Text: "A VPC, AutoScaling Group, EC2 Launch Template, and ALB for a website"},
+			},
+		},
 	}
 
 	showPrompt(selections, "")
diff --git a/internal/cmd/build/tmpl/ec2/autoscaling.yaml b/internal/cmd/build/tmpl/ec2/autoscaling.yaml
new file mode 100644
index 00000000..41285a25
--- /dev/null
+++ b/internal/cmd/build/tmpl/ec2/autoscaling.yaml
@@ -0,0 +1,246 @@
+AWSTemplateFormatVersion: "2010-09-09"
+
+Description: |
+  Create a multi-az, load balanced and Auto Scaled sample web site running on
+  an Apache Web Server. The application is configured to span all
+  Availability Zones in the region and is Auto-Scaled based on the CPU
+  utilization of the web servers. Notifications will be sent to the operator
+  email address on scaling events. The instances are load balanced with a
+  simple health check against the default web page. **WARNING** This template
+  creates one or more Amazon EC2 instances and an Elastic Load Balancer. You
+  will be billed for the AWS resources used if you create a stack from this
+  template.
+
+Parameters:
+  InstanceType:
+    Description: WebServer EC2 instance type
+    Type: String
+    Default: t4g.micro
+
+  OperatorEMail:
+    Description: Email address to notify if there are any scaling operations
+    Type: String
+
+  LatestAmiId:
+    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
+    Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64
+
+  KmsKeyArn:
+    Description: KMS Key ARN to encrypt data
+    Type: String
+
+  CertificateArn:
+    Description: Certificate ARN for HTTPS
+    Type: String
+
+Resources:
+
+  Network:
+    Type: !Rain::Module ../modules/vpc.yaml
+    Overrides:
+      VPC:
+        Properties:
+          Tags:
+            - Key: Name
+              Value: rain-build-autoscaling
+
+  ALB:
+    Type: !Rain::Module ../modules/load-balancer.yaml
+    Properties:
+      VPCId: !Ref NetworkVPC
+      PublicSubnet1: !Ref NetworkPublicSubnet1
+      PublicSubnet2: !Ref NetworkPublicSubnet2
+      DestinationSecurityGroupId: !GetAtt InstanceSecurityGroup.GroupId
+      CertificateArn: !Ref CertificateArn
+    Overrides:
+      LoadBalancer:
+        DependsOn:
+          - NetworkPublicSubnet1DefaultRoute
+          - NetworkPublicSubnet1RouteTableAssociation
+          - NetworkPublicSubnet2DefaultRoute
+          - NetworkPublicSubnet2RouteTableAssociation
+      TargetGroup:
+        Properties:
+          TargetType: instance
+
+  InstanceSecurityGroup:
+    Type: AWS::EC2::SecurityGroup
+    Properties:
+      GroupDescription: Enable HTTP from the load balancer only
+      SecurityGroupIngress:
+        - IpProtocol: tcp
+          FromPort: 80
+          ToPort: 80
+          SourceSecurityGroupId: !Ref ALBLoadBalancerSecurityGroup
+      VpcId: !Ref NetworkVPC
+
+  NotificationTopic:
+    Type: AWS::SNS::Topic
+    Properties:
+      DisplayName: !Sub ${AWS::StackName}-NotificationTopic
+      Subscription:
+        - Endpoint: !Ref OperatorEMail
+          Protocol: email
+      KmsMasterKeyId: !Ref KmsKeyArn
+
+  LaunchTemplate:
+    Type: AWS::EC2::LaunchTemplate
+    Metadata:
+      AWS::CloudFormation::Init:
+        config:
+          packages:
+            yum:
+              httpd: []
+          files:
+            /var/www/html/index.html:
+              content: |
+                  <html><body><h1>Congratulations, you have successfully launched the AWS CloudFormation sample.</h1></body></html>
+              mode: "000644"
+              owner: root
+              group: root
+            /etc/cfn/cfn-hup.conf:
+              content: !Sub |
+                [main]
+                stack=${AWS::StackId}
+                region=${AWS::Region}
+              mode: "000400"
+              owner: root
+              group: root
+            /etc/cfn/hooks.d/cfn-auto-reloader.conf:
+              content: !Sub |
+                [cfn-auto-reloader-hook]
+                triggers=post.update
+                path=Resources.LaunchTemplate.Metadata.AWS::CloudFormation::Init
+                action=/opt/aws/bin/cfn-init -v --stack ${AWS::StackName} --resource LaunchTemplate --region ${AWS::Region}
+                runas=root
+          services:
+            sysvinit:
+              httpd:
+                enabled: true
+                ensureRunning: true
+              cfn-hup:
+                enabled: true
+                ensureRunning: true
+                files:
+                  - /etc/cfn/cfn-hup.conf
+                  - /etc/cfn/hooks.d/cfn-auto-reloader.conf
+    Properties:
+      LaunchTemplateName: !Sub ${AWS::StackName}-LaunchTemplate
+      LaunchTemplateData:
+        ImageId: !Ref LatestAmiId
+        InstanceType: !Ref InstanceType
+        SecurityGroupIds: 
+          - !Ref InstanceSecurityGroup
+        BlockDeviceMappings:
+          - DeviceName: /dev/sda1
+            Ebs:
+              VolumeSize: 32
+        UserData: !Base64
+          Fn::Sub: |
+            #!/bin/bash
+            /opt/aws/bin/cfn-init -v --stack ${AWS::StackName} --resource LaunchTemplate --region ${AWS::Region}
+            /opt/aws/bin/cfn-signal -e $? --stack ${AWS::StackName} --resource WebServerGroup --region ${AWS::Region}
+        TagSpecifications:
+          - ResourceType: instance
+            Tags:
+              - Key: Name
+                Value: !Sub ${AWS::StackName}-Instance
+
+  WebServerGroup:
+    Type: AWS::AutoScaling::AutoScalingGroup
+    CreationPolicy:
+      ResourceSignal:
+        Timeout: PT5M
+        Count: 1
+    UpdatePolicy:
+      AutoScalingRollingUpdate:
+        MinInstancesInService: 1
+        MaxBatchSize: 1
+        PauseTime: PT5M
+        WaitOnResourceSignals: true
+    DependsOn: NetworkVPCGW
+    Metadata:
+      cfn-lint:
+        config:
+          ignore_checks:
+            - E3014
+    Properties:
+      AvailabilityZones: 
+        - !Select [0, Fn::GetAZs: !Ref "AWS::Region"]
+        - !Select [1, Fn::GetAZs: !Ref "AWS::Region"]
+      LaunchTemplate:
+        LaunchTemplateId: !Ref LaunchTemplate
+        Version: !GetAtt LaunchTemplate.LatestVersionNumber
+      MinSize: "1"
+      MaxSize: "3"
+      TargetGroupARNs:
+        - !Ref ALBTargetGroup
+      NotificationConfigurations:
+        - TopicARN: !Ref NotificationTopic
+          NotificationTypes:
+            - autoscaling:EC2_INSTANCE_LAUNCH
+            - autoscaling:EC2_INSTANCE_LAUNCH_ERROR
+            - autoscaling:EC2_INSTANCE_TERMINATE
+            - autoscaling:EC2_INSTANCE_TERMINATE_ERROR
+      HealthCheckType: ELB
+      VPCZoneIdentifier: 
+        - !Ref NetworkPublicSubnet1
+        - !Ref NetworkPublicSubnet2
+
+  WebServerScaleUpPolicy:
+    Type: AWS::AutoScaling::ScalingPolicy
+    Properties:
+      AdjustmentType: ChangeInCapacity
+      AutoScalingGroupName: !Ref WebServerGroup
+      Cooldown: "60"
+      ScalingAdjustment: 1
+
+  WebServerScaleDownPolicy:
+    Type: AWS::AutoScaling::ScalingPolicy
+    Properties:
+      AdjustmentType: ChangeInCapacity
+      AutoScalingGroupName: !Ref WebServerGroup
+      Cooldown: "60"
+      ScalingAdjustment: -1
+
+  CPUAlarmHigh:
+    Type: AWS::CloudWatch::Alarm
+    Properties:
+      AlarmDescription: Scale-up if CPU > 90% for 10 minutes
+      MetricName: CPUUtilization
+      Namespace: AWS/EC2
+      Statistic: Average
+      Period: 300
+      EvaluationPeriods: 2
+      Threshold: 90
+      AlarmActions:
+        - !Ref WebServerScaleUpPolicy
+      Dimensions:
+        - Name: AutoScalingGroupName
+          Value: !Ref WebServerGroup
+      ComparisonOperator: GreaterThanThreshold
+
+  CPUAlarmLow:
+    Type: AWS::CloudWatch::Alarm
+    Properties:
+      AlarmDescription: Scale-down if CPU < 70% for 10 minutes
+      MetricName: CPUUtilization
+      Namespace: AWS/EC2
+      Statistic: Average
+      Period: 300
+      EvaluationPeriods: 2
+      Threshold: 70
+      AlarmActions:
+        - !Ref WebServerScaleDownPolicy
+      Dimensions:
+        - Name: AutoScalingGroupName
+          Value: !Ref WebServerGroup
+      ComparisonOperator: LessThanThreshold
+
+Outputs:
+  URL:
+    Description: The URL of the website
+    Value: !Join
+      - ""
+      - - https://
+        - !GetAtt ALBLoadBalancer.DNSName
diff --git a/internal/cmd/build/tmpl/modules/load-balancer.yaml b/internal/cmd/build/tmpl/modules/load-balancer.yaml
index 9822829b..09c2892c 100644
--- a/internal/cmd/build/tmpl/modules/load-balancer.yaml
+++ b/internal/cmd/build/tmpl/modules/load-balancer.yaml
@@ -94,7 +94,7 @@ Resources:
   TargetGroup:
     Type: AWS::ElasticLoadBalancingV2::TargetGroup
     Properties:
-      Port: 443
+      Port: 80
       Protocol: HTTP
       TargetGroupAttributes:
         - Key: deregistration_delay.timeout_seconds
diff --git a/internal/cmd/deploy/deploy.go b/internal/cmd/deploy/deploy.go
index f860d369..5ebd8c9d 100644
--- a/internal/cmd/deploy/deploy.go
+++ b/internal/cmd/deploy/deploy.go
@@ -6,6 +6,7 @@ import (
 	"path/filepath"
 
 	"github.com/aws-cloudformation/rain/cft/format"
+	cftpkg "github.com/aws-cloudformation/rain/cft/pkg"
 	"github.com/aws-cloudformation/rain/internal/aws"
 	"github.com/aws-cloudformation/rain/internal/aws/cfn"
 	"github.com/aws-cloudformation/rain/internal/console"
@@ -28,6 +29,7 @@ var roleArn string
 var ignoreUnknownParams bool
 var noexec bool
 var changeset bool
+var experimental bool
 
 // Cmd is the deploy command's entrypoint
 var Cmd = &cobra.Command{
@@ -106,6 +108,9 @@ To list and delete changesets, use the ls and rm commands.
 			}
 
 			// Package template
+			if experimental {
+				cftpkg.Experimental = true
+			}
 			spinner.Push(fmt.Sprintf("Preparing template '%s'", base))
 			template := PackageTemplate(fn, yes)
 			spinner.Pop()
@@ -253,4 +258,5 @@ func init() {
 	Cmd.Flags().BoolVarP(&noexec, "no-exec", "x", false, "do not execute the changeset")
 	Cmd.Flags().BoolVar(&changeset, "changeset", false, "execute the changeset, rain deploy --changeset <stackName> <changeSetName>")
 	Cmd.Flags().StringVar(&format.NodeStyle, "node-style", "", format.NodeStyleDocs)
+	Cmd.Flags().BoolVar(&experimental, "experimental", false, "Acknowledge that you want to deploy with an experimental feature")
 }
diff --git a/internal/cmd/forecast/README.md b/internal/cmd/forecast/README.md
index 67a72b4a..97ff16e2 100644
--- a/internal/cmd/forecast/README.md
+++ b/internal/cmd/forecast/README.md
@@ -22,20 +22,37 @@ different role for the checks you make against the template.
 This command currently makes a few generic checks for a wide range of
 resources:
 
-- The resource already exists (for stack creation with hard coded resource
+- FG001: The resource already exists (for stack creation with hard coded resource
   names)
-- IAM permissions to interact with the resource. Keep in mind that this is a
-  slow operation and can be suppressed with the `--skip-iam` argument. It is
+- FG002: IAM permissions to interact with the resource. Keep in mind that this is a
+  slow operation and is disabled by default. You can enable it with the `--include-iam` argument. It is
   also not guaranteed to be 100% accurate, due to the difficulty with
   predicting the exact ARNs for all possible resources that are involved with
   the resource provider.
 
 ## Specific checks
 
-- For a delete operation, the S3 bucket is not empty
-- An S3 bucket policy has an invalid principal
-- Make sure RDS cluster configuration makes sense for the chosen engine 
-
+These can be ignored with the `--ignore` argument.
+
+| Code  | Description                                                                    |                                      
+|-------|--------------------------------------------------------------------------------|
+| F0001 | For a delete operation, the S3 bucket is not empty                             |
+| F0002 | S3 bucket policy has an invalid principal                                      |
+| F0003 | RDS cluster configuration is correct for the chosen engine                     |
+| F0004 | RDS monitoring role arn is correct                                             |
+| F0005 | RDS cluster quota is not at limit                                              |
+| F0006 | RDS instance configuration is correct for the chosen engine                    |
+| F0007 | EC2 instance and launch template KeyName exists                                |
+| F0008 | EC2 instance and launch template InstanceType exists                           |
+| F0009 | EC2 instance and launch template instance type and AMI match                   |
+| F0010 | Within the same template, are all security groups pointing to the same network |
+| F0011 | If there is no default VPC, does each security group have a vpc configured?    |
+| F0012 | Certificate not found for elastic load balancer                                |
+| F0013 | SNS Topic Key is valid                                                         |
+| F0014 | ELB target group Port and Protocol match                                       |
+| F0015 | ELB target groups must be of type instance if they are used by an ASG          |
+| F0016 | Lambda function role exists                                                    |
+| F0017 | Lambda function role can be assumed                                            |
 ## Estimates
 
 The forecast command also tries to estimate how long it thinks your stack will
@@ -49,9 +66,6 @@ You can view the issues list for the forecast command
 Please feel free to create an issue here whenever you get a stack failure that
 you think could have been prevented by one of these checks.
 
-In the near term, we're going to add the ability to suppress specific checks by
-giving each of them a code.
-
 Checks we plan to implement:
 
 - DynamoDB global table replica region requirements
@@ -63,8 +77,6 @@ Checks we plan to implement:
 - Function version does not exist
 - Warn on resource replacements for active traffic
 - API gateway account trust permission
-- Security group exists
-- Is an EC2 instance type available in the AZ
 
 
 
diff --git a/internal/cmd/forecast/autoscaling.go b/internal/cmd/forecast/autoscaling.go
new file mode 100644
index 00000000..ac1ae280
--- /dev/null
+++ b/internal/cmd/forecast/autoscaling.go
@@ -0,0 +1,17 @@
+package forecast
+
+/* TODO
+Resource handler returned message:
+The availability zones of the specified subnets and the
+Auto Scaling group do not match (Service: AutoScaling
+*/
+
+/* TODO
+Resource handler returned message: "Provided Target Group
+'arn:aws:elasticloadbalancing:us-east-1:755952356119:targetgroup/MyTargetGroup/bd36b34c22052e00'
+has invalid target type. Please ensure all provided Target Groups have target
+type of instance. (Service: AutoScaling
+
+This should probably be a cfn-lint check instead.
+
+*/
diff --git a/internal/cmd/forecast/codes.go b/internal/cmd/forecast/codes.go
new file mode 100644
index 00000000..8362dbc0
--- /dev/null
+++ b/internal/cmd/forecast/codes.go
@@ -0,0 +1,28 @@
+package forecast
+
+const (
+	FG001 = "FG001"
+	FG002 = "FG002"
+	F0001 = "F0001"
+	F0002 = "F0002"
+	F0003 = "F0003"
+	F0004 = "F0004"
+	F0005 = "F0005"
+	F0006 = "F0006"
+	F0007 = "F0007"
+	F0008 = "F0008"
+	F0009 = "F0009"
+	F0010 = "F0010"
+	F0011 = "F0011"
+	F0012 = "F0012"
+	F0013 = "F0013"
+	F0014 = "F0014"
+	F0015 = "F0015"
+	F0016 = "F0016"
+	F0017 = "F0017"
+	F0018 = "F0018"
+	F0019 = "F0019"
+	F0020 = "F0020"
+	F0021 = "F0021"
+	F0022 = "F0022"
+)
diff --git a/internal/cmd/forecast/ec2_instance.go b/internal/cmd/forecast/ec2_instance.go
index ed5a4d77..22ffbc28 100644
--- a/internal/cmd/forecast/ec2_instance.go
+++ b/internal/cmd/forecast/ec2_instance.go
@@ -1,21 +1,23 @@
 package forecast
 
 import (
+	"fmt"
 	"github.com/aws-cloudformation/rain/internal/aws/ec2"
+	"github.com/aws-cloudformation/rain/internal/aws/ssm"
 	"github.com/aws-cloudformation/rain/internal/config"
 	"github.com/aws-cloudformation/rain/internal/console/spinner"
 	"github.com/aws-cloudformation/rain/internal/s11n"
 	"gopkg.in/yaml.v3"
+	"slices"
+	"strings"
 )
 
 func checkKeyName(input *PredictionInput, forecast *Forecast) {
 
 	var keyName string
 
-	// Check to see if the resource has the KeyName property set
-	_, props, _ := s11n.GetMapValue(input.resource, "Properties")
+	props := getPropNode(input)
 	if props == nil {
-		config.Debugf("expected %s to have Properties", input.logicalId)
 		return
 	}
 
@@ -38,10 +40,11 @@ func checkKeyName(input *PredictionInput, forecast *Forecast) {
 			spin(input.typeName, input.logicalId, "EC2 instance key exists?")
 
 			exists, _ := ec2.CheckKeyPairExists(keyName)
+			code := F0007
 			if exists {
-				forecast.Add(true, "Key exists")
+				forecast.Add(code, true, "Key exists")
 			} else {
-				forecast.Add(false, "Key does not exist")
+				forecast.Add(code, false, "Key does not exist")
 			}
 
 			spinner.Pop()
@@ -51,15 +54,134 @@ func checkKeyName(input *PredictionInput, forecast *Forecast) {
 	}
 }
 
-func checkEC2Instance(input PredictionInput) Forecast {
+func resolveImageId(imageId string) string {
+	// Strings in CloudFormation can look like this:
+	// {{resolve:ssm:name}}
+	// Make an API call to systems manager parameter store to get the value
+
+	// TODO: This should probably move to where we resolve Refs
+
+	// Check to see if the image id starts with {{resolve:ssm:
+	if strings.HasPrefix(imageId, "{{resolve:ssm:") {
+		// The image id is a parameter name
+		imageId = strings.TrimPrefix(imageId, "{{resolve:ssm:")
+		imageId = strings.TrimSuffix(imageId, "}}")
+
+		config.Debugf("resolving %s", imageId)
+
+		resolved, err := ssm.GetParameter(imageId)
+		if err != nil {
+			config.Debugf("failed to resolve %s: %v", imageId, err)
+			return ""
+		}
+		config.Debugf("resolved %s to %s", imageId, resolved)
+		return resolved
+	}
+
+	// TODO: Secrets manager
+
+	return imageId
+}
+
+// checkInstanceType checks to see if the AMI and the instance type are compatible
+func checkInstanceType(input *PredictionInput, forecast *Forecast) {
+
+	var instanceType string
+	code := F0008
+
+	props := getPropNode(input)
+	if props == nil {
+		return
+	}
+
+	_, instanceTypeProp, _ := s11n.GetMapValue(props, "InstanceType")
+	if instanceTypeProp == nil {
+		config.Debugf("%s does not have InstanceType", input.logicalId)
+		return
+	}
+
+	// If the name is a Ref, resolve it
+	if instanceTypeProp.Kind == yaml.ScalarNode {
+		// The name is hard coded
+		instanceType = instanceTypeProp.Value
+	} else {
+		// We resolved Refs earlier so it should be a string
+		config.Debugf("%s.InstanceType is not a string", input.logicalId)
+		return
+	}
+
+	// Call the DescribeInstanceTypes API to get the instance type info
+	spin(input.typeName, input.logicalId, "EC2 instance type exists?")
+	instanceTypeInfo, err := ec2.GetInstanceType(instanceType)
+	if err != nil {
+		forecast.Add(code, false, err.Error())
+	} else {
+		forecast.Add(code, true, "Instance type exists")
+	}
+	spinner.Pop()
+
+	// Make sure the instance type and AMI agree
 
-	// TODO - Is this instance type available in this AZ?
+	_, imageIdNode, _ := s11n.GetMapValue(props, "ImageId")
+	if imageIdNode == nil {
+		config.Debugf("%s does not have ImageId", input.logicalId)
+		return
+	}
+
+	imageId := resolveImageId(imageIdNode.Value)
+
+	spin(input.typeName, input.logicalId, "EC2 instance type matches AMI?")
+	image, err := ec2.GetImage(imageId)
+	if err != nil {
+		forecast.Add(F0009, false, fmt.Sprintf("Image not found: %s", imageId))
+		spinner.Pop()
+		return
+	}
+
+	instanceTypesForArch, err := ec2.GetInstanceTypesForArchitecture(string(image.Architecture))
+	if err != nil {
+		config.Debugf("failed to get instance types for architecture %s: %v", image.Architecture, err)
+		spinner.Pop()
+		return
+	}
+	code = F0009
+	if !slices.Contains(instanceTypesForArch, string(instanceTypeInfo.InstanceType)) {
+		forecast.Add(code, false, fmt.Sprintf("Instance type %s does not support AMI %s", instanceType, imageId))
+	} else {
+		forecast.Add(code, true, "Instance type matches AMI")
+	}
+	spinner.Pop()
+}
+
+func getPropNode(input *PredictionInput) *yaml.Node {
+	// Check to see if the resource has the InstanceType property set
+	_, props, _ := s11n.GetMapValue(input.resource, "Properties")
+	if props == nil {
+		config.Debugf("expected %s to have Properties", input.logicalId)
+		return nil
+	}
+
+	// If the input resource is an AWS::EC2::LaunchTemplate, props is LaunchTemplateData
+	if input.typeName == "AWS::EC2::LaunchTemplate" {
+		_, props, _ = s11n.GetMapValue(props, "LaunchTemplateData")
+		if props == nil {
+			config.Debugf("expected %s to have LaunchTemplateData", input.logicalId)
+			return nil
+		}
+	}
+	return props
+}
+
+func checkEC2Instance(input PredictionInput) Forecast {
 
 	forecast := makeForecast(input.typeName, input.logicalId)
 
 	// Check to see if the key name exists
 	checkKeyName(&input, &forecast)
 
+	// Make sure the AMI and the instance type are compatible
+	checkInstanceType(&input, &forecast)
+
 	return forecast
 
 }
diff --git a/internal/cmd/forecast/ec2_launchtemplate.go b/internal/cmd/forecast/ec2_launchtemplate.go
new file mode 100644
index 00000000..c8cbe629
--- /dev/null
+++ b/internal/cmd/forecast/ec2_launchtemplate.go
@@ -0,0 +1,17 @@
+package forecast
+
+// AWS::EC2::LaunchTemplate
+
+func checkEC2LaunchTemplate(input PredictionInput) Forecast {
+
+	forecast := makeForecast(input.typeName, input.logicalId)
+
+	// Check to see if the key name exists
+	checkKeyName(&input, &forecast)
+
+	// Make sure the AMI and the instance type are compatible
+	checkInstanceType(&input, &forecast)
+
+	return forecast
+
+}
diff --git a/internal/cmd/forecast/ec2_securitygroup.go b/internal/cmd/forecast/ec2_securitygroup.go
index 00e26dad..2ad140c2 100644
--- a/internal/cmd/forecast/ec2_securitygroup.go
+++ b/internal/cmd/forecast/ec2_securitygroup.go
@@ -1,20 +1,20 @@
 package forecast
 
+import (
+	"fmt"
+
+	"github.com/aws-cloudformation/rain/cft"
+	"github.com/aws-cloudformation/rain/internal/aws/ec2"
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/aws-cloudformation/rain/internal/console/spinner"
+	"github.com/aws-cloudformation/rain/internal/s11n"
+	"gopkg.in/yaml.v3"
+)
+
 func checkEC2SecurityGroup(input PredictionInput) Forecast {
 
 	// TODO - Cannot delete a security group that still has instances
 	// (See if there are instances not in this stack)
-	/* CLI commands
-
-	aws ec2 describe-network-interfaces --filters Name=group-id,Values=<group-id> --region <region> --output json
-
-	*/
-
-	//	filters := []types.Filter{}
-
-	//	input := *ec2.DescribeNetworkInterfacesInput{
-	//		Filters:
-	//	}
 
 	// TODO - Make sure security group exists in VPC
 	/*
@@ -30,6 +30,113 @@ func checkEC2SecurityGroup(input PredictionInput) Forecast {
 
 	forecast := makeForecast(input.typeName, input.logicalId)
 
+	spin(input.typeName, input.logicalId, "Checking if all security groups are in the same VPC")
+	checkSecurityGroupsInSameVPC(&input, &forecast)
+	spinner.Pop()
+
 	return forecast
 
 }
+
+// Not sure if we can accurately check if the security group is in use,
+// since the instances may come from this template and will be deleted first.
+// We could cross-check the physical IDs if it's actually an instance,
+// but more likely will be instances from an ASG and how do we correlate
+// them with the security group? Likely to have false positives.
+
+//// checkSecurityGroupIsInUse checks on deletes if a security group cannot be deleted
+//func checkSecurityGroupIsInUse(input *PredictionInput, forecast *Forecast) {
+//	if input.stackExists && action == DELETE {
+//		// Check if the security group is in use
+//		sgInUse, err := ec2.IsSecurityGroupInUse(input.stackName)
+//		if err != nil {
+//			forecast.Add(false, fmt.Sprintf("%v", err))
+//			return
+//		}
+//		if sgInUse {
+//			msg := fmt.Sprintf("Security group is in use by other resources")
+//			forecast.Add(false, msg)
+//			return
+//		}
+//		forecast.Add(true, "Security group is not in use by other resources")
+//	}
+//}
+
+func checkSecurityGroupsInSameVPC(input *PredictionInput, forecast *Forecast) {
+
+	code := F0010
+
+	// Get the default VPC
+	defaultVPCId, err := ec2.GetDefaultVPCId()
+	if err != nil {
+		msg := fmt.Sprintf("Unable to get default VPC Id: %v", err)
+		forecast.Add(code, false, msg)
+		return
+	}
+
+	config.Debugf("defaultVPCId: %s", defaultVPCId)
+
+	resources, err := input.source.GetSection(cft.Resources)
+	if err != nil {
+		config.Debugf("Unable to get Resources: %v", err)
+		return
+	}
+
+	var vpcId string
+
+	// Iterate through resources to find all security groups
+	for i := 0; i < len(resources.Content); i += 2 {
+
+		thisLogicalId := resources.Content[i].Value
+
+		_, typ, _ := s11n.GetMapValue(resources.Content[i+1], "Type")
+		if typ == nil {
+			config.Debugf("expected %s to have Type", resources.Content[i].Value)
+			continue
+		}
+		if typ.Value != "AWS::EC2::SecurityGroup" {
+			continue
+		}
+		_, props, _ := s11n.GetMapValue(resources.Content[i+1], "Properties")
+		if props == nil {
+			config.Debugf("expected %s to have Properties", resources.Content[i].Value)
+			continue
+		}
+
+		// See if VpcId is set. If not, assume it's the default.
+		// If it is hard coded or set in a param, make sure they are all the same
+		_, v, _ := s11n.GetMapValue(props, "VpcId")
+		var resourceVpcId string
+		if v != nil {
+			if v.Kind == yaml.ScalarNode {
+				resourceVpcId = v.Value
+			} else if v.Kind == yaml.MappingNode {
+				// Likely a Ref to the VPC created in this template
+				if v.Content[0].Value == "Ref" {
+					resourceVpcId = v.Content[1].Value
+				}
+			}
+			if resourceVpcId == "" {
+				// Not sure what this is
+				config.Debugf("Unable to determine VpcId for %s", input.logicalId)
+				return
+			}
+		} else {
+			resourceVpcId = defaultVPCId
+			if defaultVPCId == "" {
+				msg := fmt.Sprintf("There is no default VPC and VpcId is not set on %s", thisLogicalId)
+				forecast.Add(F0011, false, msg)
+			}
+		}
+
+		if vpcId == "" {
+			vpcId = resourceVpcId
+		} else if resourceVpcId != vpcId {
+			msg := fmt.Sprintf("VPC ID for this security group (%s) does not match %s", resourceVpcId, vpcId)
+			forecast.Add(code, false, msg)
+			return
+		}
+
+	}
+	forecast.Add(code, true, "All VPC Ids on security groups are the same")
+}
diff --git a/internal/cmd/forecast/elb.go b/internal/cmd/forecast/elb.go
new file mode 100644
index 00000000..2ea0a3bb
--- /dev/null
+++ b/internal/cmd/forecast/elb.go
@@ -0,0 +1,129 @@
+package forecast
+
+import (
+	"github.com/aws-cloudformation/rain/internal/aws/acm"
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/aws-cloudformation/rain/internal/console/spinner"
+	"github.com/aws-cloudformation/rain/internal/node"
+	"github.com/aws-cloudformation/rain/internal/s11n"
+	"gopkg.in/yaml.v3"
+)
+
+// Elastic Load Balancing
+
+// AWS::ElasticLoadBalancingV2::Listener
+
+func checkELBListener(input PredictionInput) Forecast {
+
+	forecast := makeForecast(input.typeName, input.logicalId)
+
+	// Resource handler returned message:
+	// "Certificate 'arn:aws:acm:us-east-1:X:certificate/Y'
+	//not found (Service: ElasticLoadBalancingV2
+	// (Expired certs cause this error)
+
+	// Get the certificate arn from props
+	_, props, _ := s11n.GetMapValue(input.resource, "Properties")
+	if props == nil {
+		config.Debugf("expected %s to have Properties", input.logicalId)
+		return forecast
+	}
+
+	_, certArns, _ := s11n.GetMapValue(props, "Certificates")
+	if certArns == nil {
+		config.Debugf("expected %s to have Certificates", input.logicalId)
+		return forecast
+	}
+	config.Debugf("Certificates: %s", node.ToSJson(certArns))
+
+	spin(input.typeName, input.logicalId, "Checking ELB Certs")
+
+	for _, certArnNode := range certArns.Content {
+		if len(certArnNode.Content) != 2 {
+			config.Debugf("Expected 2 children for certArnNode %s", node.ToSJson(certArnNode))
+			continue
+		}
+		certArn := certArnNode.Content[1].Value
+		config.Debugf("Checking certArn %s", certArn)
+		ok, err := acm.CheckCertificate(certArn)
+		if err != nil {
+			config.Debugf("Error checking certArn %s: %s", certArn, err)
+			spinner.Pop()
+			return forecast
+		}
+		code := F0012
+		if !ok {
+			forecast.Add(code, false, "Certificate not found or expired")
+		} else {
+			forecast.Add(code, true, "Certificate found")
+		}
+	}
+
+	spinner.Pop()
+
+	return forecast
+}
+
+func checkELBTargetGroup(input PredictionInput) Forecast {
+
+	forecast := makeForecast(input.typeName, input.logicalId)
+
+	// Check to make sure the Port and Protocol properties match
+	portNode := input.GetPropertyNode("Port")
+	protocolNode := input.GetPropertyNode("Protocol")
+	if portNode != nil && protocolNode != nil {
+		port := portNode.Value
+		protocol := protocolNode.Value
+		if port == "443" {
+			if protocol == "HTTPS" {
+				forecast.Add(F0014, true, "ELB target group port and protocol match")
+			} else {
+				forecast.Add(F0014, false, "ELB target group port and protocol do not match")
+			}
+		}
+		if port == "80" {
+			if protocol == "HTTP" {
+				forecast.Add(F0014, true, "ELB target group port and protocol match")
+			} else {
+				forecast.Add(F0014, false, "ELB target group port and protocol do not match")
+			}
+		}
+	}
+
+	targetTypeNode := input.GetPropertyNode("TargetType")
+	if targetTypeNode != nil {
+		targetType := targetTypeNode.Value
+		if targetType != "instance" {
+			// If this target group is being used by an ASG, the type must be instance
+			// Look at template resources to see if a launch template refers to this
+			autoscalingGroups := input.source.GetResourcesOfType("AWS::AutoScaling::AutoScalingGroup")
+			for _, asg := range autoscalingGroups {
+				_, props, _ := s11n.GetMapValue(asg, "Properties")
+				if props == nil {
+					continue
+				}
+				config.Debugf("Properties: %s", node.ToSJson(props))
+				_, targetGroupARNs, _ := s11n.GetMapValue(props, "TargetGroupARNs")
+				if targetGroupARNs == nil {
+					continue
+				}
+				for _, targetGroupArn := range targetGroupARNs.Content {
+					if targetGroupArn.Kind == yaml.MappingNode {
+						if targetGroupArn.Content[0].Kind == yaml.ScalarNode &&
+							targetGroupArn.Content[0].Value == "Ref" {
+							if targetGroupArn.Content[1].Value == input.logicalId {
+								forecast.Add(F0015, false,
+									"ELB target group must be of type instance if it is used by an ASG")
+							} else {
+								forecast.Add(F0015, true,
+									"ELB target group is of type instance if it is used by an ASG")
+							}
+						}
+					}
+				}
+			}
+		}
+	}
+
+	return forecast
+}
diff --git a/internal/cmd/forecast/forecast.go b/internal/cmd/forecast/forecast.go
index 343485c7..afc5ce6e 100644
--- a/internal/cmd/forecast/forecast.go
+++ b/internal/cmd/forecast/forecast.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"slices"
 	"strings"
 
 	"github.com/aws-cloudformation/rain/cft"
@@ -36,8 +37,8 @@ var Experimental bool
 // ResourceType is the resource type to check (optional --type to limit checks to one type)
 var ResourceType string
 
-// SkipIAM indicates if we should perform permissions checks or not, to save time
-var SkipIAM bool
+// IncludeIAM indicates if we should perform permissions checks or not, to save time
+var IncludeIAM bool
 
 // The optional parameters to use to create a change set for update predictions (--params)
 var params []string
@@ -51,6 +52,19 @@ var configFilePath string
 // Show success in addition to failures
 var all bool
 
+// Which stack action to check (--action)
+var action string
+
+// Which checks to ignore (--ignore)
+var ignore []string
+
+const (
+	ALL    = "all"
+	CREATE = "create"
+	UPDATE = "update"
+	DELETE = "delete"
+)
+
 type Env struct {
 	partition string
 	region    string
@@ -71,6 +85,16 @@ type PredictionInput struct {
 	roleArn     string
 }
 
+// GetPropertyNode returns the node for the given property name
+func (input *PredictionInput) GetPropertyNode(name string) *yaml.Node {
+	_, props, _ := s11n.GetMapValue(input.resource, "Properties")
+	if props != nil {
+		_, n, _ := s11n.GetMapValue(props, name)
+		return n
+	}
+	return nil
+}
+
 // LineNumber is the current line number in the template
 var LineNumber int
 
@@ -78,8 +102,15 @@ var LineNumber int
 type Forecast struct {
 	TypeName  string
 	LogicalId string
-	Passed    []string
-	Failed    []string
+	Passed    []Check
+	Failed    []Check
+}
+
+// Check is a specific check with a code that can be suppressed
+type Check struct {
+	Pass    bool
+	Code    string
+	Message string
 }
 
 func (f *Forecast) GetNumChecked() int {
@@ -100,22 +131,42 @@ func (f *Forecast) Append(forecast Forecast) {
 }
 
 // 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) {
+func (f *Forecast) Add(code string, passed bool, message string) {
 	msg := fmt.Sprintf("%v: %v %v - %v", LineNumber, f.TypeName, f.LogicalId, message)
+	check := Check{
+		Pass:    passed,
+		Code:    code,
+		Message: msg,
+	}
+
+	// If we are ignoring this check, don't add it to the forecast
+	if slices.Contains(ignore, code) || slices.Contains(ignore, f.TypeName) {
+		return
+	}
+
 	if passed {
-		f.Passed = append(f.Passed, msg)
+		f.Passed = append(f.Passed, check)
+	} else {
+		f.Failed = append(f.Failed, check)
+	}
+}
+
+func (c *Check) String() string {
+	var passFail string
+	if c.Pass {
+		passFail = "PASS"
 	} else {
-		f.Failed = append(f.Failed, msg)
+		passFail = "FAIL"
 	}
-	// TODO - Do we want each failure to have a code so that it can be ignored?
+	return fmt.Sprintf("%s %s on line %s", c.Code, passFail, c.Message)
 }
 
 func makeForecast(typeName string, logicalId string) Forecast {
 	return Forecast{
 		TypeName:  typeName,
 		LogicalId: logicalId,
-		Passed:    make([]string, 0),
-		Failed:    make([]string, 0),
+		Passed:    make([]Check, 0),
+		Failed:    make([]Check, 0),
 	}
 }
 
@@ -127,40 +178,6 @@ func spin(typeName string, logicalId string, message string) {
 	spinner.Push(fmt.Sprintf("%v %v - %v", typeName, logicalId, message))
 }
 
-// recurse over properties to resolve Refs
-func resolveParamRefs(name string, prop *yaml.Node, dc *dc.DeployConfig, parent *yaml.Node) {
-	if name == "Ref" && prop.Kind == yaml.ScalarNode {
-
-		for _, param := range dc.Params {
-			if *param.ParameterKey == prop.Value {
-				if parent.Kind == yaml.MappingNode {
-					// Replace the parent Mapping node
-					*parent = yaml.Node{Kind: yaml.ScalarNode, Value: *param.ParameterValue}
-				}
-				// would it be any other Kind?
-			}
-		}
-
-	} else if prop.Kind == yaml.MappingNode {
-		for i := 0; i < len(prop.Content); i += 2 {
-			resolveParamRefs(prop.Content[i].Value, prop.Content[i+1], dc, prop)
-		}
-	} else if prop.Kind == yaml.SequenceNode {
-		for _, p := range prop.Content {
-			resolveParamRefs("", p, dc, prop)
-		}
-	}
-}
-
-func resolveRefs(input PredictionInput) {
-	_, props, _ := s11n.GetMapValue(input.resource, "Properties")
-	if props != nil {
-		for i := 0; i < len(props.Content); i += 2 {
-			resolveParamRefs(props.Content[i].Value, props.Content[i+1], input.dc, props)
-		}
-	}
-}
-
 // Run all forecasters for the type
 func forecastForType(input PredictionInput) Forecast {
 
@@ -176,15 +193,15 @@ func forecastForType(input PredictionInput) Forecast {
 	// Resolve parameter refs
 	resolveRefs(input)
 
-	// Estimate how long the action will take
+	// Estimate how long the stackActionToEstimate will take
 	// (This is only for spinner output, we calculate total time separately)
-	var action StackAction
+	var stackActionToEstimate StackAction
 	if input.stackExists {
-		action = Update
+		stackActionToEstimate = Update
 	} else {
-		action = Create
+		stackActionToEstimate = Create
 	}
-	est, esterr := GetResourceEstimate(input.typeName, action)
+	est, esterr := GetResourceEstimate(input.typeName, stackActionToEstimate)
 	if esterr != nil {
 		config.Debugf("could not get estimate: %v", esterr)
 		est = 1
@@ -198,19 +215,20 @@ func forecastForType(input PredictionInput) Forecast {
 
 	spin(input.typeName, input.logicalId, "exists already?")
 
+	code := FG001
 	// 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")
+		forecast.Add(code, false, "Already exists")
 	} else {
 		LineNumber = input.resource.Line
-		forecast.Add(true, "Does not exist")
+		forecast.Add(code, true, "Does not exist")
 	}
 
 	spinner.Pop()
 
 	// Check permissions
-	if !SkipIAM {
+	if IncludeIAM {
 		err := checkPermissions(input, &forecast)
 		if err != nil {
 			config.Debugf("Unable to check permissions: %v", err)
@@ -234,6 +252,7 @@ func forecastForType(input PredictionInput) Forecast {
 	if found {
 		// Call the prediction function and append the results
 		config.Debugf("Running forecaster for %v", input.typeName)
+		LineNumber = input.resource.Line
 		forecast.Append(fn(input))
 	}
 
@@ -253,8 +272,6 @@ func predict(source cft.Template, stackName string, stack types.Stack, stackExis
 	// 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]
@@ -335,7 +352,7 @@ func predict(source cft.Template, stackName string, stack types.Stack, stackExis
 			forecast.GetNumFailed(),
 			forecast.GetNumChecked())))
 		for _, reason := range forecast.Failed {
-			fmt.Println(console.Red(reason))
+			fmt.Println(console.Red(reason.String()))
 		}
 		if all {
 			fmt.Println()
@@ -344,7 +361,7 @@ func predict(source cft.Template, stackName string, stack types.Stack, stackExis
 				forecast.GetNumPassed(),
 				forecast.GetNumChecked())))
 			for _, reason := range forecast.Passed {
-				fmt.Println(console.Green(reason))
+				fmt.Println(console.Green(reason.String()))
 			}
 		}
 
@@ -357,7 +374,7 @@ func predict(source cft.Template, stackName string, stack types.Stack, stackExis
 		if all {
 			fmt.Println()
 			for _, reason := range forecast.Passed {
-				fmt.Println(console.Green(reason))
+				fmt.Println(console.Green(reason.String()))
 			}
 		}
 		return true
@@ -386,17 +403,8 @@ This command is not a linter! Use cfn-lint for that. The forecast command
 is concerned with things that could go wrong during deployment, after the 
 template has been checked to make sure it has a valid syntax.
 
-This command checks for some common issues across all resources:
-
-- The resource already exists
-- You do not have permissions to create/update/delete the resource
-- (More to come.. service quotas, drift issues)
-
-Resource-specific checks:
-
-- S3 bucket is not empty
-- S3 bucket policy has an invalid principal
-- (Many more to come...)
+This command checks for some common issues across all resources, and 
+resource-specific checks. See the README for more details.
 `,
 	Args:                  cobra.RangeArgs(1, 2),
 	DisableFlagsInUseLine: true,
@@ -448,6 +456,14 @@ Resource-specific checks:
 		}
 		config.Debugf("Stack %v %v", stackName, msg)
 
+		if action == DELETE || action == UPDATE && !stackExists {
+			panic(fmt.Sprintf("stack %v does not exist, action %s is not valid", stackName, action))
+		}
+
+		if action == CREATE && stackExists {
+			panic(fmt.Sprintf("stack %v already exists, action %s is not valid", stackName, action))
+		}
+
 		dc, err := dc.GetDeployConfig(tags, params, configFilePath, base,
 			source, stack, stackExists, true, false)
 		if err != nil {
@@ -463,15 +479,16 @@ Resource-specific checks:
 
 func init() {
 	Cmd.Flags().BoolVar(&config.Debug, "debug", false, "Output debugging information")
-	Cmd.Flags().BoolVar(&SkipIAM, "skip-iam", false, "Skip permissions checks, which can take a long time")
+	Cmd.Flags().BoolVar(&IncludeIAM, "include-iam", false, "Include permissions checks, which can take a long time")
 	Cmd.Flags().BoolVarP(&all, "all", "a", false, "Show all checks, not just failed ones")
 	Cmd.Flags().BoolVarP(&Experimental, "experimental", "x", false, "Acknowledge that this is an experimental feature")
 	Cmd.Flags().StringVar(&RoleArn, "role-arn", "", "An optional execution role arn to use for predicting IAM failures")
-	// TODO - --op "create", "update", "delete", default: "all"
 	Cmd.Flags().StringVar(&ResourceType, "type", "", "Optional resource type to limit checks to only that type")
 	Cmd.Flags().StringSliceVar(&tags, "tags", []string{}, "add tags to the stack; use the format key1=value1,key2=value2")
 	Cmd.Flags().StringSliceVar(&params, "params", []string{}, "set parameter values; use the format key1=value1,key2=value2")
 	Cmd.Flags().StringVarP(&configFilePath, "config", "c", "", "YAML or JSON file to set tags and parameters")
+	Cmd.Flags().StringVar(&action, "action", ALL, "The stack action to check: create, update, delete, all (default is all)")
+	Cmd.Flags().StringSliceVar(&ignore, "ignore", []string{}, "Resource types and specific codes to ignore, separated by commas, for example, AWS::S3::Bucket,F0002")
 
 	// If you want to add a prediction for a type that is not already covered, add it here
 	// The function must return a Forecast struct
@@ -484,6 +501,11 @@ func init() {
 	forecasters["AWS::EC2::SecurityGroup"] = checkEC2SecurityGroup
 	forecasters["AWS::RDS::DBCluster"] = checkRDSDBCluster
 	forecasters["AWS::AutoScaling::LaunchConfiguration"] = checkAutoScalingLaunchConfiguration
+	forecasters["AWS::EC2::LaunchTemplate"] = checkEC2LaunchTemplate
+	forecasters["AWS::ElasticLoadBalancingV2::Listener"] = checkELBListener
+	forecasters["AWS::SNS::Topic"] = checkSNSTopic
+	forecasters["AWS::ElasticLoadBalancingV2::TargetGroup"] = checkELBTargetGroup
+	forecasters["AWS::Lambda::Function"] = checkLambdaFunction
 
 	// Initialize estimates map
 	InitEstimates()
diff --git a/internal/cmd/forecast/lambda.go b/internal/cmd/forecast/lambda.go
new file mode 100644
index 00000000..04a77feb
--- /dev/null
+++ b/internal/cmd/forecast/lambda.go
@@ -0,0 +1,46 @@
+package forecast
+
+import (
+	"github.com/aws-cloudformation/rain/internal/aws/iam"
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/aws-cloudformation/rain/internal/s11n"
+	"gopkg.in/yaml.v3"
+)
+
+// checkLambdaFunction checks for potential stack failures related to functions
+func checkLambdaFunction(input PredictionInput) Forecast {
+
+	forecast := makeForecast(input.typeName, input.logicalId)
+
+	_, props, _ := s11n.GetMapValue(input.resource, "Properties")
+	if props == nil {
+		config.Debugf("No Properties found for %s", input.logicalId)
+		return forecast
+	}
+	_, roleProp, _ := s11n.GetMapValue(props, "Role")
+
+	// If the role is specified, and it's a scalar, check if it exists
+	if roleProp != nil && roleProp.Kind == yaml.ScalarNode {
+		roleArn := roleProp.Value
+		LineNumber = roleProp.Line
+		if !iam.RoleExists(roleArn) {
+			forecast.Add(F0016, false, "Role does not exist")
+		} else {
+			forecast.Add(F0016, true, "Role exists")
+		}
+
+		// Check to make sure the iam role can be assumed by the lambda function
+		canAssume, err := iam.CanAssumeRole(roleArn, "lambda.amazonaws.com")
+		if err != nil {
+			config.Debugf("Error checking role: %s", err)
+		} else {
+			if !canAssume {
+				forecast.Add(F0017, false, "Role can not be assumed")
+			} else {
+				forecast.Add(F0017, true, "Role can be assumed")
+			}
+		}
+	}
+
+	return forecast
+}
diff --git a/internal/cmd/forecast/permissions.go b/internal/cmd/forecast/permissions.go
index d15c9000..c50307b7 100644
--- a/internal/cmd/forecast/permissions.go
+++ b/internal/cmd/forecast/permissions.go
@@ -90,31 +90,33 @@ func checkPermissions(input PredictionInput, forecast *Forecast) error {
 		return nil
 	}
 
+	code := FG002
+
 	var ok bool
 	var reason []string
 	if input.stackExists {
 		ok, reason = checkTypePermissions(input, resourceArn, "update")
 		if !ok {
-			forecast.Add(false,
+			forecast.Add(code, false,
 				fmt.Sprintf("Insufficient permissions to update %v\n\t%v", resourceArn, strings.Join(reason, "\n\t")))
 		} else {
-			forecast.Add(true, "Role has update permissions")
+			forecast.Add(code, true, "Role has update permissions")
 		}
 
 		ok, reason = checkTypePermissions(input, resourceArn, "delete")
 		if !ok {
-			forecast.Add(false,
+			forecast.Add(code, false,
 				fmt.Sprintf("Insufficient permissions to delete %v\n\t%v", resourceArn, strings.Join(reason, "\n\t")))
 		} else {
-			forecast.Add(true, "Role has delete permissions")
+			forecast.Add(code, true, "Role has delete permissions")
 		}
 	} else {
 		ok, reason = checkTypePermissions(input, resourceArn, "create")
 		if !ok {
-			forecast.Add(false,
+			forecast.Add(code, false,
 				fmt.Sprintf("Insufficient permissions to create %v\n\t%v", resourceArn, strings.Join(reason, "\n\t")))
 		} else {
-			forecast.Add(true, "Role has create permissions")
+			forecast.Add(code, true, "Role has create permissions")
 		}
 	}
 	return nil
diff --git a/internal/cmd/forecast/rds_dbcluster.go b/internal/cmd/forecast/rds_dbcluster.go
index 6e833ad7..a09c0ee4 100644
--- a/internal/cmd/forecast/rds_dbcluster.go
+++ b/internal/cmd/forecast/rds_dbcluster.go
@@ -32,6 +32,8 @@ func checkRDSDBCluster(input PredictionInput) Forecast {
 
 	var clusterEngineVersion string
 
+	code := F0003
+
 	_, engine, _ := s11n.GetMapValue(props, "Engine")
 	_, engineVersion, _ := s11n.GetMapValue(props, "EngineVersion")
 	if engineVersion != nil {
@@ -58,19 +60,21 @@ func checkRDSDBCluster(input PredictionInput) Forecast {
 			if unexpected {
 				LineNumber = input.resource.Line
 				config.Debugf("db cluster resource: %s", node.ToJson(input.resource))
-				forecast.Add(false, fmt.Sprintf("unexpected EngineVersion: %s", engineVersion.Value))
+				forecast.Add(code, false, fmt.Sprintf("unexpected EngineVersion: %s", engineVersion.Value))
 			} else {
-				forecast.Add(true, "EngineVersion ok")
+				forecast.Add(code, true, "EngineVersion ok")
 			}
 		default:
 			config.Debugf("unexpected Engine value for %s: %s",
 				input.logicalId, engine.Value)
-			forecast.Add(false, "unexpected Engine value")
+			forecast.Add(code, false, "unexpected Engine value")
 		}
 	}
 
 	spinner.Pop()
 
+	code = F0004
+
 	spin(input.typeName, input.logicalId, "db cluster has MonitoringRoleARN?")
 
 	// Resource handler returned message: A MonitoringRoleARN value is required if you specify a MonitoringInterval value other than 0.
@@ -78,41 +82,43 @@ func checkRDSDBCluster(input PredictionInput) Forecast {
 	_, monitoringInterval, _ := s11n.GetMapValue(props, "MonitoringInterval")
 	if monitoringInterval != nil && monitoringInterval.Value != "0" {
 		if monitoringRoleARN == nil {
-			forecast.Add(false, "a MonitoringRoleARN value is required if you specify a MonitoringInterval value other than 0.")
+			forecast.Add(code, false, "a MonitoringRoleARN value is required if you specify a MonitoringInterval value other than 0.")
 		} else {
 			// Make sure the role actually exists
 			if monitoringRoleARN.Kind == yaml.ScalarNode &&
 				!iam.RoleExists(monitoringRoleARN.Value) {
-				forecast.Add(false,
+				forecast.Add(code, false,
 					fmt.Sprintf("MonitoringRoleARN not found: %s",
 						monitoringRoleARN.Value))
 			} else {
-				forecast.Add(true, "MonitoringRoleARN set")
+				forecast.Add(code, true, "MonitoringRoleARN set")
 			}
 		}
 	} else {
-		forecast.Add(true, "MonitoringInterval not set to something other than 0")
+		forecast.Add(code, true, "MonitoringInterval not set to something other than 0")
 	}
 
 	spinner.Pop()
 
+	code = F0005
+
 	spin(input.typeName, input.logicalId, "db clusters not at quota")
 
 	// Check to make sure we're not at quota
 	if !input.stackExists {
 		quota, err := servicequotas.GetQuota("rds", "L-952B80B8")
 		if err != nil {
-			forecast.Add(false, fmt.Sprintf("failed: %v", err))
+			forecast.Add(code, false, fmt.Sprintf("failed: %v", err))
 		} else {
 			// Get the number of clusters
 			numClusters, err := rds.GetNumClusters()
 			if err != nil {
-				forecast.Add(false, fmt.Sprintf("failed: %v", err))
+				forecast.Add(code, false, fmt.Sprintf("failed: %v", err))
 			} else {
 				if numClusters >= int(math.Round(quota)) {
-					forecast.Add(false, "already at quota for number of clusters")
+					forecast.Add(code, false, "already at quota for number of clusters")
 				} else {
-					forecast.Add(true,
+					forecast.Add(code, true,
 						fmt.Sprintf("quota for clusters ok: %v/%v",
 							numClusters, quota))
 				}
@@ -122,6 +128,8 @@ func checkRDSDBCluster(input PredictionInput) Forecast {
 
 	spinner.Pop()
 
+	code = F0006
+
 	// The engine version that you requested for your DB instance (a.b) does not match the engine version of your DB cluster (c.d)
 	// This kind of thing might be better in cfn-lint
 
@@ -153,11 +161,11 @@ func checkRDSDBCluster(input PredictionInput) Forecast {
 
 							instanceVersion := evNode.Value
 							if evNode.Kind == yaml.ScalarNode && instanceVersion != clusterEngineVersion {
-								forecast.Add(false, fmt.Sprintf(
+								forecast.Add(code, false, fmt.Sprintf(
 									"engine mismatch with %s: %s != %s",
 									logicalId, instanceVersion, clusterEngineVersion))
 							} else {
-								forecast.Add(true, "instance engine version matches")
+								forecast.Add(code, true, "instance engine version matches")
 							}
 						}
 					}
diff --git a/internal/cmd/forecast/resolve.go b/internal/cmd/forecast/resolve.go
new file mode 100644
index 00000000..d28aca08
--- /dev/null
+++ b/internal/cmd/forecast/resolve.go
@@ -0,0 +1,65 @@
+package forecast
+
+import (
+	"github.com/aws-cloudformation/rain/internal/aws/ssm"
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/aws-cloudformation/rain/internal/dc"
+	"github.com/aws-cloudformation/rain/internal/s11n"
+	"gopkg.in/yaml.v3"
+	"strings"
+)
+
+// recurse over properties to resolve Refs
+func resolveParamRefs(name string, prop *yaml.Node, dc *dc.DeployConfig, parent *yaml.Node) {
+	if name == "Ref" && prop.Kind == yaml.ScalarNode {
+
+		for _, param := range dc.Params {
+			if *param.ParameterKey == prop.Value {
+				if parent.Kind == yaml.MappingNode {
+
+					var val string
+
+					// Resolve SSM types like AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
+					if param.ResolvedValue != nil {
+						// Will this ever not be nil? Maybe for updates?
+						val = *param.ResolvedValue
+					} else {
+						val = *param.ParameterValue
+						// We don't have the param type here...
+						if strings.HasPrefix(val, "/aws/service/") {
+							// Assume this is an SSM parameter
+							resolved, err := ssm.GetParameter(val)
+							if err != nil {
+								config.Debugf("could not get SSM parameter: %v", err)
+							} else {
+								val = resolved
+							}
+						}
+					}
+
+					// Replace the parent Mapping node
+					*parent = yaml.Node{Kind: yaml.ScalarNode, Value: val}
+				}
+				// would it be any other Kind?
+			}
+		}
+
+	} else if prop.Kind == yaml.MappingNode {
+		for i := 0; i < len(prop.Content); i += 2 {
+			resolveParamRefs(prop.Content[i].Value, prop.Content[i+1], dc, prop)
+		}
+	} else if prop.Kind == yaml.SequenceNode {
+		for _, p := range prop.Content {
+			resolveParamRefs("", p, dc, prop)
+		}
+	}
+}
+
+func resolveRefs(input PredictionInput) {
+	_, props, _ := s11n.GetMapValue(input.resource, "Properties")
+	if props != nil {
+		for i := 0; i < len(props.Content); i += 2 {
+			resolveParamRefs(props.Content[i].Value, props.Content[i+1], input.dc, props)
+		}
+	}
+}
diff --git a/internal/cmd/forecast/s3_bucket.go b/internal/cmd/forecast/s3_bucket.go
index 59d7a0b8..85e9afe0 100644
--- a/internal/cmd/forecast/s3_bucket.go
+++ b/internal/cmd/forecast/s3_bucket.go
@@ -18,6 +18,10 @@ func checkBucketNotEmpty(input PredictionInput, bucket *types.StackResourceDetai
 		return true, "Stack does not exist"
 	}
 
+	if bucket == nil {
+		return false, "bucket is nil"
+	}
+
 	spin(input.typeName, input.logicalId, "bucket not empty?")
 
 	config.Debugf("Checking if the bucket %v is not empty", *bucket.PhysicalResourceId)
@@ -71,10 +75,11 @@ func checkS3Bucket(input PredictionInput) Forecast {
 			config.Debugf("Physical bucket name is: %v", bucketName)
 
 			empty, reason := checkBucketNotEmpty(input, res)
+			code := F0001
 			if !empty {
-				forecast.Add(false, reason)
+				forecast.Add(code, false, reason)
 			} else {
-				forecast.Add(true, "Bucket is empty")
+				forecast.Add(code, true, "Bucket is empty")
 			}
 		}
 	} else {
diff --git a/internal/cmd/forecast/s3_bucketpolicy.go b/internal/cmd/forecast/s3_bucketpolicy.go
index 0edce837..05c1728d 100644
--- a/internal/cmd/forecast/s3_bucketpolicy.go
+++ b/internal/cmd/forecast/s3_bucketpolicy.go
@@ -38,15 +38,17 @@ func checkS3BucketPolicy(input PredictionInput) Forecast {
 		if policyDocument != nil {
 			res, err := iam.CheckPolicyDocument(policyDocument)
 
+			code := F0002
+
 			if err != nil {
-				forecast.Add(false, fmt.Sprintf("Unable to check policy document: %v", err))
+				forecast.Add(code, false, fmt.Sprintf("Unable to check policy document: %v", err))
 			}
 
 			if !res {
 				LineNumber = policyDocument.Line
-				forecast.Add(false, "Invalid principal in policy document")
+				forecast.Add(code, false, "Invalid principal in policy document")
 			} else {
-				forecast.Add(true, "Principal is valid")
+				forecast.Add(code, true, "Principal is valid")
 			}
 		}
 	}
diff --git a/internal/cmd/forecast/sns_topic.go b/internal/cmd/forecast/sns_topic.go
new file mode 100644
index 00000000..43a8e8b8
--- /dev/null
+++ b/internal/cmd/forecast/sns_topic.go
@@ -0,0 +1,32 @@
+package forecast
+
+import (
+	"github.com/aws-cloudformation/rain/internal/aws/kms"
+	"github.com/aws-cloudformation/rain/internal/console/spinner"
+)
+
+func checkSNSTopic(input PredictionInput) Forecast {
+
+	forecast := makeForecast(input.typeName, input.logicalId)
+
+	spin(input.typeName, input.logicalId, "Checking SNS Topic Key")
+	checkSNSTopicKey(&input, &forecast)
+	spinner.Pop()
+
+	return forecast
+}
+
+func checkSNSTopicKey(input *PredictionInput, forecast *Forecast) {
+
+	// Get the KmsMasterKeyId from the input resource properties
+	k := input.GetPropertyNode("KmsMasterKeyId")
+	if k != nil {
+		keyArn := k.Value
+		valid := kms.IsKeyArnValid(keyArn)
+		if valid {
+			forecast.Add(F0013, true, "KMS Key is valid")
+		} else {
+			forecast.Add(F0013, false, "KMS Key is invalid")
+		}
+	}
+}
diff --git a/internal/cmd/ls/changeset.go b/internal/cmd/ls/changeset.go
index a847e3a5..b050ba58 100644
--- a/internal/cmd/ls/changeset.go
+++ b/internal/cmd/ls/changeset.go
@@ -43,6 +43,9 @@ func showChangeset(stackName, changeSetName string) {
 		panic(ui.Errorf(err, "failed to get changeset '%s'", changeSetName))
 	}
 	config.Debugf("ChangeSet response: %+v", cs)
+	if cs == nil {
+		return
+	}
 	out := ""
 	out += fmt.Sprintf("Arn: %v\n", *cs.ChangeSetId)
 	out += fmt.Sprintf("Created: %v\n", cs.CreationTime)
diff --git a/internal/dc/dc.go b/internal/dc/dc.go
index 0a4a0f58..359f03f9 100644
--- a/internal/dc/dc.go
+++ b/internal/dc/dc.go
@@ -1,4 +1,4 @@
-// The dc package contains types and functions to facilitate parsing
+// Package dc contains types and functions to facilitate parsing
 // user-supplied configuration like tags and parameters, which
 // are used for the deploy and forecast commands
 package dc


Test patch:
diff --git a/internal/aws/iam/iam_test.go b/internal/aws/iam/iam_test.go
index f996f88b..ceec07b4 100644
--- a/internal/aws/iam/iam_test.go
+++ b/internal/aws/iam/iam_test.go
@@ -10,3 +10,14 @@ func TestTransformCallerArn2(t *testing.T) {
 		t.Errorf("Failed to transform IAM user type arn")
 	}
 }
+
+func TestGetRoleNameFromArn(t *testing.T) {
+	roleArn := "arn:aws:iam::755952356119:role/my-role"
+	roleName, err := GetRoleNameFromArn(roleArn)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if roleName != "my-role" {
+		t.Errorf("Failed to get role name from arn: %s", roleArn)
+	}
+}
diff --git a/internal/cmd/forecast/forecast_test.go b/internal/cmd/forecast/forecast_test.go
index 3a206818..0a6ab67e 100644
--- a/internal/cmd/forecast/forecast_test.go
+++ b/internal/cmd/forecast/forecast_test.go
@@ -4,8 +4,8 @@ import "testing"
 
 func TestForecastMethods(t *testing.T) {
 	forecast := makeForecast("A::B::C", "Id")
-	forecast.Add(true, "Succeeded")
-	forecast.Add(false, "Failed")
+	forecast.Add("CODE1", true, "Succeeded")
+	forecast.Add("CODE2", false, "Failed")
 
 	if forecast.GetNumChecked() != 2 {
 		t.Errorf("Expected 2 checks")
@@ -20,7 +20,7 @@ func TestForecastMethods(t *testing.T) {
 	}
 
 	f2 := makeForecast("D::E::F", "Id2")
-	f2.Add(false, "f2 fail")
+	f2.Add("CODE2", false, "f2 fail")
 	forecast.Append(f2)
 
 	if forecast.GetNumChecked() != 3 || forecast.GetNumFailed() != 2 {
diff --git a/test/templates/forecast/autoscaling-fail.yaml b/test/templates/forecast/autoscaling-fail.yaml
new file mode 100644
index 00000000..48e1c8ee
--- /dev/null
+++ b/test/templates/forecast/autoscaling-fail.yaml
@@ -0,0 +1,496 @@
+AWSTemplateFormatVersion: "2010-09-09"
+
+Description: |
+  Create a multi-az, load balanced and Auto Scaled sample web site running on
+  an Apache Web Server. The application is configured to span all
+  Availability Zones in the region and is Auto-Scaled based on the CPU
+  utilization of the web servers. Notifications will be sent to the operator
+  email address on scaling events. The instances are load balanced with a
+  simple health check against the default web page. **WARNING** This template
+  creates one or more Amazon EC2 instances and an Elastic Load Balancer. You
+  will be billed for the AWS resources used if you create a stack from this
+  template.
+
+Parameters:
+  InstanceType:
+    Description: WebServer EC2 instance type
+    Type: String
+    Default: t4g.micro
+
+  OperatorEMail:
+    Description: Email address to notify if there are any scaling operations
+    Type: String
+
+  LatestAmiId:
+    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
+    Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64
+
+  KmsKeyArn:
+    Description: KMS Key ARN to encrypt data
+    Type: String
+
+  CertificateArn:
+    Description: Certificate ARN for HTTPS
+    Type: String
+
+Resources:
+  InstanceSecurityGroup:
+    Type: AWS::EC2::SecurityGroup
+    Metadata:
+      Comment: Without VpcId this defaults to the default VPC which is not NetworkVPC
+    Properties:
+      GroupDescription: Enable HTTP from the load balancer only
+      SecurityGroupIngress:
+        - IpProtocol: tcp
+          FromPort: 80
+          ToPort: 80
+          SourceSecurityGroupId: !Ref ALBLoadBalancerSecurityGroup
+
+  NotificationTopic:
+    Type: AWS::SNS::Topic
+    Metadata:
+      Comment: If the key arn is not valid the ASG will fail to create
+    Properties:
+      DisplayName: !Sub ${AWS::StackName}-NotificationTopic
+      Subscription:
+        - Endpoint: !Ref OperatorEMail
+          Protocol: email
+      KmsMasterKeyId: !Ref KmsKeyArn
+
+  LaunchTemplate:
+    Type: AWS::EC2::LaunchTemplate
+    Metadata:
+      AWS::CloudFormation::Init:
+        config:
+          packages:
+            yum:
+              httpd: []
+          files:
+            /var/www/html/index.html:
+              content: |
+                <html><body><h1>Congratulations, you have successfully launched the AWS CloudFormation sample.</h1></body></html>
+              mode: "000644"
+              owner: root
+              group: root
+            /etc/cfn/cfn-hup.conf:
+              content: !Sub |
+                [main]
+                stack=${AWS::StackId}
+                region=${AWS::Region}
+              mode: "000400"
+              owner: root
+              group: root
+            /etc/cfn/hooks.d/cfn-auto-reloader.conf:
+              content: !Sub |
+                [cfn-auto-reloader-hook]
+                triggers=post.update
+                path=Resources.LaunchTemplate.Metadata.AWS::CloudFormation::Init
+                action=/opt/aws/bin/cfn-init -v --stack ${AWS::StackName} --resource LaunchTemplate --region ${AWS::Region}
+                runas=root
+          services:
+            sysvinit:
+              httpd:
+                enabled: true
+                ensureRunning: true
+              cfn-hup:
+                enabled: true
+                ensureRunning: true
+                files:
+                  - /etc/cfn/cfn-hup.conf
+                  - /etc/cfn/hooks.d/cfn-auto-reloader.conf
+    Properties:
+      LaunchTemplateName: !Sub ${AWS::StackName}-LaunchTemplate
+      LaunchTemplateData:
+        ImageId: !Ref LatestAmiId
+        InstanceType: !Ref InstanceType
+        SecurityGroupIds:
+          - !Ref InstanceSecurityGroup
+        BlockDeviceMappings:
+          - DeviceName: /dev/sda1
+            Ebs:
+              VolumeSize: 32
+        UserData: !Base64
+          Fn::Sub: |
+            #!/bin/bash
+            /opt/aws/bin/cfn-init -v --stack ${AWS::StackName} --resource LaunchTemplate --region ${AWS::Region}
+            /opt/aws/bin/cfn-signal -e $? --stack ${AWS::StackName} --resource WebServerGroup --region ${AWS::Region}
+        TagSpecifications:
+          - ResourceType: instance
+            Tags:
+              - Key: Name
+                Value: !Sub ${AWS::StackName}-Instance
+
+  WebServerGroup:
+    Type: AWS::AutoScaling::AutoScalingGroup
+    CreationPolicy:
+      ResourceSignal:
+        Timeout: PT5M
+        Count: 1
+    UpdatePolicy:
+      AutoScalingRollingUpdate:
+        MinInstancesInService: 1
+        MaxBatchSize: 1
+        PauseTime: PT5M
+        WaitOnResourceSignals: true
+    DependsOn: NetworkVPCGW
+    Metadata:
+      cfn-lint:
+        config:
+          ignore_checks:
+            - E3014
+    Properties:
+      AvailabilityZones:
+        - !Select
+          - 0
+          - !GetAZs
+            Ref: AWS::Region
+        - !Select
+          - 1
+          - !GetAZs
+            Ref: AWS::Region
+      LaunchTemplate:
+        LaunchTemplateId: !Ref LaunchTemplate
+        Version: !GetAtt LaunchTemplate.LatestVersionNumber
+      MinSize: "1"
+      MaxSize: "3"
+      TargetGroupARNs:
+        - !Ref ALBTargetGroup
+      NotificationConfigurations:
+        - TopicARN: !Ref NotificationTopic
+          NotificationTypes:
+            - autoscaling:EC2_INSTANCE_LAUNCH
+            - autoscaling:EC2_INSTANCE_LAUNCH_ERROR
+            - autoscaling:EC2_INSTANCE_TERMINATE
+            - autoscaling:EC2_INSTANCE_TERMINATE_ERROR
+      HealthCheckType: ELB
+      VPCZoneIdentifier:
+        - !Ref NetworkPublicSubnet1
+        - !Ref NetworkPublicSubnet2
+
+  WebServerScaleUpPolicy:
+    Type: AWS::AutoScaling::ScalingPolicy
+    Properties:
+      AdjustmentType: ChangeInCapacity
+      AutoScalingGroupName: !Ref WebServerGroup
+      Cooldown: "60"
+      ScalingAdjustment: 1
+
+  WebServerScaleDownPolicy:
+    Type: AWS::AutoScaling::ScalingPolicy
+    Properties:
+      AdjustmentType: ChangeInCapacity
+      AutoScalingGroupName: !Ref WebServerGroup
+      Cooldown: "60"
+      ScalingAdjustment: -1
+
+  CPUAlarmHigh:
+    Type: AWS::CloudWatch::Alarm
+    Properties:
+      AlarmDescription: Scale-up if CPU > 90% for 10 minutes
+      MetricName: CPUUtilization
+      Namespace: AWS/EC2
+      Statistic: Average
+      Period: 300
+      EvaluationPeriods: 2
+      Threshold: 90
+      AlarmActions:
+        - !Ref WebServerScaleUpPolicy
+      Dimensions:
+        - Name: AutoScalingGroupName
+          Value: !Ref WebServerGroup
+      ComparisonOperator: GreaterThanThreshold
+
+  CPUAlarmLow:
+    Type: AWS::CloudWatch::Alarm
+    Properties:
+      AlarmDescription: Scale-down if CPU < 70% for 10 minutes
+      MetricName: CPUUtilization
+      Namespace: AWS/EC2
+      Statistic: Average
+      Period: 300
+      EvaluationPeriods: 2
+      Threshold: 70
+      AlarmActions:
+        - !Ref WebServerScaleDownPolicy
+      Dimensions:
+        - Name: AutoScalingGroupName
+          Value: !Ref WebServerGroup
+      ComparisonOperator: LessThanThreshold
+
+  NetworkVPC:
+    Type: AWS::EC2::VPC
+    Properties:
+      CidrBlock: 10.0.0.0/16
+      EnableDnsHostnames: true
+      EnableDnsSupport: true
+      InstanceTenancy: default
+      Tags:
+        - Key: Name
+          Value: rain-build-autoscaling-fail
+
+  NetworkPublicSubnet1:
+    Type: AWS::EC2::Subnet
+    Properties:
+      AvailabilityZone: !Select
+        - 0
+        - !GetAZs
+          Ref: AWS::Region
+      CidrBlock: 10.0.0.0/18
+      MapPublicIpOnLaunch: true
+      VpcId: !Ref NetworkVPC
+    Metadata:
+      guard:
+        SuppressedRules:
+          - SUBNET_AUTO_ASSIGN_PUBLIC_IP_DISABLED
+
+  NetworkPublicSubnet1RouteTable:
+    Type: AWS::EC2::RouteTable
+    Properties:
+      VpcId: !Ref NetworkVPC
+
+  NetworkPublicSubnet1RouteTableAssociation:
+    Type: AWS::EC2::SubnetRouteTableAssociation
+    Properties:
+      RouteTableId: !Ref NetworkPublicSubnet1RouteTable
+      SubnetId: !Ref NetworkPublicSubnet1
+
+  NetworkPublicSubnet1DefaultRoute:
+    Type: AWS::EC2::Route
+    Properties:
+      DestinationCidrBlock: 0.0.0.0/0
+      GatewayId: !Ref NetworkInternetGateway
+      RouteTableId: !Ref NetworkPublicSubnet1RouteTable
+    Metadata:
+      guard:
+        SuppressedRules:
+          - NO_UNRESTRICTED_ROUTE_TO_IGW
+    DependsOn:
+      - NetworkVPCGW
+
+  NetworkPublicSubnet1EIP:
+    Type: AWS::EC2::EIP
+    Properties:
+      Domain: vpc
+
+  NetworkPublicSubnet1NATGateway:
+    Type: AWS::EC2::NatGateway
+    Properties:
+      AllocationId: !GetAtt NetworkPublicSubnet1EIP.AllocationId
+      SubnetId: !Ref NetworkPublicSubnet1
+    DependsOn:
+      - NetworkPublicSubnet1DefaultRoute
+      - NetworkPublicSubnet1RouteTableAssociation
+
+  NetworkPublicSubnet2:
+    Type: AWS::EC2::Subnet
+    Properties:
+      AvailabilityZone: !Select
+        - 1
+        - !GetAZs
+          Ref: AWS::Region
+      CidrBlock: 10.0.64.0/18
+      MapPublicIpOnLaunch: true
+      VpcId: !Ref NetworkVPC
+    Metadata:
+      guard:
+        SuppressedRules:
+          - SUBNET_AUTO_ASSIGN_PUBLIC_IP_DISABLED
+
+  NetworkPublicSubnet2RouteTable:
+    Type: AWS::EC2::RouteTable
+    Properties:
+      VpcId: !Ref NetworkVPC
+
+  NetworkPublicSubnet2RouteTableAssociation:
+    Type: AWS::EC2::SubnetRouteTableAssociation
+    Properties:
+      RouteTableId: !Ref NetworkPublicSubnet2RouteTable
+      SubnetId: !Ref NetworkPublicSubnet2
+
+  NetworkPublicSubnet2DefaultRoute:
+    Type: AWS::EC2::Route
+    Properties:
+      DestinationCidrBlock: 0.0.0.0/0
+      GatewayId: !Ref NetworkInternetGateway
+      RouteTableId: !Ref NetworkPublicSubnet2RouteTable
+    Metadata:
+      guard:
+        SuppressedRules:
+          - NO_UNRESTRICTED_ROUTE_TO_IGW
+    DependsOn:
+      - NetworkVPCGW
+
+  NetworkPublicSubnet2EIP:
+    Type: AWS::EC2::EIP
+    Properties:
+      Domain: vpc
+
+  NetworkPublicSubnet2NATGateway:
+    Type: AWS::EC2::NatGateway
+    Properties:
+      AllocationId: !GetAtt NetworkPublicSubnet2EIP.AllocationId
+      SubnetId: !Ref NetworkPublicSubnet2
+    DependsOn:
+      - NetworkPublicSubnet2DefaultRoute
+      - NetworkPublicSubnet2RouteTableAssociation
+
+  NetworkPrivateSubnet1Subnet:
+    Type: AWS::EC2::Subnet
+    Properties:
+      AvailabilityZone: !Select
+        - 0
+        - !GetAZs
+          Ref: AWS::Region
+      CidrBlock: 10.0.128.0/18
+      MapPublicIpOnLaunch: false
+      VpcId: !Ref NetworkVPC
+
+  NetworkPrivateSubnet1RouteTable:
+    Type: AWS::EC2::RouteTable
+    Properties:
+      VpcId: !Ref NetworkVPC
+
+  NetworkPrivateSubnet1RouteTableAssociation:
+    Type: AWS::EC2::SubnetRouteTableAssociation
+    Properties:
+      RouteTableId: !Ref NetworkPrivateSubnet1RouteTable
+      SubnetId: !Ref NetworkPrivateSubnet1Subnet
+
+  NetworkPrivateSubnet1DefaultRoute:
+    Type: AWS::EC2::Route
+    Properties:
+      DestinationCidrBlock: 0.0.0.0/0
+      NatGatewayId: !Ref NetworkPublicSubnet1NATGateway
+      RouteTableId: !Ref NetworkPrivateSubnet1RouteTable
+
+  NetworkPrivateSubnet2Subnet:
+    Type: AWS::EC2::Subnet
+    Properties:
+      AvailabilityZone: !Select
+        - 1
+        - !GetAZs
+          Ref: AWS::Region
+      CidrBlock: 10.0.192.0/18
+      MapPublicIpOnLaunch: false
+      VpcId: !Ref NetworkVPC
+
+  NetworkPrivateSubnet2RouteTable:
+    Type: AWS::EC2::RouteTable
+    Properties:
+      VpcId: !Ref NetworkVPC
+
+  NetworkPrivateSubnet2RouteTableAssociation:
+    Type: AWS::EC2::SubnetRouteTableAssociation
+    Properties:
+      RouteTableId: !Ref NetworkPrivateSubnet2RouteTable
+      SubnetId: !Ref NetworkPrivateSubnet2Subnet
+
+  NetworkPrivateSubnet2DefaultRoute:
+    Type: AWS::EC2::Route
+    Properties:
+      DestinationCidrBlock: 0.0.0.0/0
+      NatGatewayId: !Ref NetworkPublicSubnet2NATGateway
+      RouteTableId: !Ref NetworkPrivateSubnet2RouteTable
+
+  NetworkInternetGateway:
+    Type: AWS::EC2::InternetGateway
+
+  NetworkVPCGW:
+    Type: AWS::EC2::VPCGatewayAttachment
+    Properties:
+      InternetGatewayId: !Ref NetworkInternetGateway
+      VpcId: !Ref NetworkVPC
+
+  ALBLoadBalancer:
+    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
+    Properties:
+      LoadBalancerAttributes:
+        - Key: deletion_protection.enabled
+          Value: false
+        - Key: routing.http.drop_invalid_header_fields.enabled
+          Value: true
+      Scheme: internet-facing
+      SecurityGroups:
+        - !GetAtt ALBLoadBalancerSecurityGroup.GroupId
+      Subnets:
+        - !Ref NetworkPublicSubnet1
+        - !Ref NetworkPublicSubnet2
+      Type: application
+    Metadata:
+      checkov:
+        skip:
+          - id: CKV_AWS_91
+      guard:
+        SuppressedRules:
+          - ELB_DELETION_PROTECTION_ENABLED
+    DependsOn:
+      - NetworkPublicSubnet1DefaultRoute
+      - NetworkPublicSubnet1RouteTableAssociation
+      - NetworkPublicSubnet2DefaultRoute
+      - NetworkPublicSubnet2RouteTableAssociation
+
+  ALBLoadBalancerSecurityGroup:
+    Type: AWS::EC2::SecurityGroup
+    Properties:
+      GroupDescription: Automatically created Security Group for ELB
+      SecurityGroupIngress:
+        - CidrIp: 0.0.0.0/0
+          Description: Allow from anyone on port 443
+          FromPort: 443
+          IpProtocol: tcp
+          ToPort: 443
+      VpcId: !Ref NetworkVPC
+
+  ALBLoadBalancerEgress:
+    Type: AWS::EC2::SecurityGroupEgress
+    Properties:
+      Description: Load balancer to target
+      DestinationSecurityGroupId: !GetAtt InstanceSecurityGroup.GroupId
+      FromPort: 80
+      GroupId: !GetAtt ALBLoadBalancerSecurityGroup.GroupId
+      IpProtocol: tcp
+      ToPort: 80
+
+  ALBLoadBalancerListener:
+    Type: AWS::ElasticLoadBalancingV2::Listener
+    Properties:
+      DefaultActions:
+        - TargetGroupArn: !Ref ALBTargetGroup
+          Type: forward
+      LoadBalancerArn: !Ref ALBLoadBalancer
+      Port: 443
+      Protocol: HTTPS
+      Certificates:
+        - CertificateArn: !Ref CertificateArn
+      SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06
+    Metadata:
+      guard:
+        SuppressedRules:
+          - ELBV2_ACM_CERTIFICATE_REQUIRED
+
+  ALBTargetGroup:
+    Type: AWS::ElasticLoadBalancingV2::TargetGroup
+    Metadata:
+      Comment: | 
+        The Port and Protocol should match, 
+        and the target type should be instance if this group
+        is being used with a launch template
+    Properties:
+      Port: 80
+      Protocol: HTTP
+      TargetGroupAttributes:
+        - Key: deregistration_delay.timeout_seconds
+          Value: "10"
+        - Key: stickiness.enabled
+          Value: "false"
+      TargetType: ip
+      VpcId: !Ref NetworkVPC
+
+Outputs:
+  URL:
+    Description: The URL of the website
+    Value: !Join
+      - ""
+      - - https://
+        - !GetAtt ALBLoadBalancer.DNSName
+
diff --git a/test/templates/forecast/autoscaling-succeed.yaml b/test/templates/forecast/autoscaling-succeed.yaml
new file mode 100644
index 00000000..8f180821
--- /dev/null
+++ b/test/templates/forecast/autoscaling-succeed.yaml
@@ -0,0 +1,496 @@
+AWSTemplateFormatVersion: "2010-09-09"
+
+Description: |
+  Create a multi-az, load balanced and Auto Scaled sample web site running on
+  an Apache Web Server. The application is configured to span all
+  Availability Zones in the region and is Auto-Scaled based on the CPU
+  utilization of the web servers. Notifications will be sent to the operator
+  email address on scaling events. The instances are load balanced with a
+  simple health check against the default web page. **WARNING** This template
+  creates one or more Amazon EC2 instances and an Elastic Load Balancer. You
+  will be billed for the AWS resources used if you create a stack from this
+  template.
+
+Parameters:
+  InstanceType:
+    Description: WebServer EC2 instance type
+    Type: String
+    Default: t4g.micro
+
+  OperatorEMail:
+    Description: Email address to notify if there are any scaling operations
+    Type: String
+
+  LatestAmiId:
+    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
+    Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64
+
+  KmsKeyArn:
+    Description: KMS Key ARN to encrypt data
+    Type: String
+
+  CertificateArn:
+    Description: Certificate ARN for HTTPS
+    Type: String
+
+Resources:
+  InstanceSecurityGroup:
+    Type: AWS::EC2::SecurityGroup
+    Properties:
+      GroupDescription: Enable HTTP from the load balancer only
+      SecurityGroupIngress:
+        - IpProtocol: tcp
+          FromPort: 80
+          ToPort: 80
+          SourceSecurityGroupId: !Ref ALBLoadBalancerSecurityGroup
+      VpcId: !Ref NetworkVPC
+
+  NotificationTopic:
+    Type: AWS::SNS::Topic
+    Properties:
+      DisplayName: !Sub ${AWS::StackName}-NotificationTopic
+      Subscription:
+        - Endpoint: !Ref OperatorEMail
+          Protocol: email
+      KmsMasterKeyId: !Ref KmsKeyArn
+
+  LaunchTemplate:
+    Type: AWS::EC2::LaunchTemplate
+    Metadata:
+      AWS::CloudFormation::Init:
+        config:
+          packages:
+            yum:
+              httpd: []
+          files:
+            /var/www/html/index.html:
+              content: |
+                <html><body><h1>Congratulations, you have successfully launched the AWS CloudFormation sample.</h1></body></html>
+              mode: "000644"
+              owner: root
+              group: root
+            /etc/cfn/cfn-hup.conf:
+              content: !Sub |
+                [main]
+                stack=${AWS::StackId}
+                region=${AWS::Region}
+              mode: "000400"
+              owner: root
+              group: root
+            /etc/cfn/hooks.d/cfn-auto-reloader.conf:
+              content: !Sub |
+                [cfn-auto-reloader-hook]
+                triggers=post.update
+                path=Resources.LaunchTemplate.Metadata.AWS::CloudFormation::Init
+                action=/opt/aws/bin/cfn-init -v --stack ${AWS::StackName} --resource LaunchTemplate --region ${AWS::Region}
+                runas=root
+          services:
+            sysvinit:
+              httpd:
+                enabled: true
+                ensureRunning: true
+              cfn-hup:
+                enabled: true
+                ensureRunning: true
+                files:
+                  - /etc/cfn/cfn-hup.conf
+                  - /etc/cfn/hooks.d/cfn-auto-reloader.conf
+    Properties:
+      LaunchTemplateName: !Sub ${AWS::StackName}-LaunchTemplate
+      LaunchTemplateData:
+        ImageId: !Ref LatestAmiId
+        InstanceType: !Ref InstanceType
+        SecurityGroupIds:
+          - !Ref InstanceSecurityGroup
+        BlockDeviceMappings:
+          - DeviceName: /dev/sda1
+            Ebs:
+              VolumeSize: 32
+        UserData: !Base64
+          Fn::Sub: |
+            #!/bin/bash
+            /opt/aws/bin/cfn-init -v --stack ${AWS::StackName} --resource LaunchTemplate --region ${AWS::Region}
+            /opt/aws/bin/cfn-signal -e $? --stack ${AWS::StackName} --resource WebServerGroup --region ${AWS::Region}
+        TagSpecifications:
+          - ResourceType: instance
+            Tags:
+              - Key: Name
+                Value: !Sub ${AWS::StackName}-Instance
+
+  WebServerGroup:
+    Type: AWS::AutoScaling::AutoScalingGroup
+    CreationPolicy:
+      ResourceSignal:
+        Timeout: PT5M
+        Count: 1
+    UpdatePolicy:
+      AutoScalingRollingUpdate:
+        MinInstancesInService: 1
+        MaxBatchSize: 1
+        PauseTime: PT5M
+        WaitOnResourceSignals: true
+    DependsOn: NetworkVPCGW
+    Metadata:
+      cfn-lint:
+        config:
+          ignore_checks:
+            - E3014
+    Properties:
+      AvailabilityZones:
+        - !Select
+          - 0
+          - !GetAZs
+            Ref: AWS::Region
+        - !Select
+          - 1
+          - !GetAZs
+            Ref: AWS::Region
+      LaunchTemplate:
+        LaunchTemplateId: !Ref LaunchTemplate
+        Version: !GetAtt LaunchTemplate.LatestVersionNumber
+      MinSize: "1"
+      MaxSize: "3"
+      TargetGroupARNs:
+        - !Ref ALBTargetGroup
+      NotificationConfigurations:
+        - TopicARN: !Ref NotificationTopic
+          NotificationTypes:
+            - autoscaling:EC2_INSTANCE_LAUNCH
+            - autoscaling:EC2_INSTANCE_LAUNCH_ERROR
+            - autoscaling:EC2_INSTANCE_TERMINATE
+            - autoscaling:EC2_INSTANCE_TERMINATE_ERROR
+      HealthCheckType: ELB
+      VPCZoneIdentifier:
+        - !Ref NetworkPublicSubnet1
+        - !Ref NetworkPublicSubnet2
+
+  WebServerScaleUpPolicy:
+    Type: AWS::AutoScaling::ScalingPolicy
+    Properties:
+      AdjustmentType: ChangeInCapacity
+      AutoScalingGroupName: !Ref WebServerGroup
+      Cooldown: "60"
+      ScalingAdjustment: 1
+
+  WebServerScaleDownPolicy:
+    Type: AWS::AutoScaling::ScalingPolicy
+    Properties:
+      AdjustmentType: ChangeInCapacity
+      AutoScalingGroupName: !Ref WebServerGroup
+      Cooldown: "60"
+      ScalingAdjustment: -1
+
+  CPUAlarmHigh:
+    Type: AWS::CloudWatch::Alarm
+    Properties:
+      AlarmDescription: Scale-up if CPU > 90% for 10 minutes
+      MetricName: CPUUtilization
+      Namespace: AWS/EC2
+      Statistic: Average
+      Period: 300
+      EvaluationPeriods: 2
+      Threshold: 90
+      AlarmActions:
+        - !Ref WebServerScaleUpPolicy
+      Dimensions:
+        - Name: AutoScalingGroupName
+          Value: !Ref WebServerGroup
+      ComparisonOperator: GreaterThanThreshold
+
+  CPUAlarmLow:
+    Type: AWS::CloudWatch::Alarm
+    Properties:
+      AlarmDescription: Scale-down if CPU < 70% for 10 minutes
+      MetricName: CPUUtilization
+      Namespace: AWS/EC2
+      Statistic: Average
+      Period: 300
+      EvaluationPeriods: 2
+      Threshold: 70
+      AlarmActions:
+        - !Ref WebServerScaleDownPolicy
+      Dimensions:
+        - Name: AutoScalingGroupName
+          Value: !Ref WebServerGroup
+      ComparisonOperator: LessThanThreshold
+
+  NetworkVPC:
+    Type: AWS::EC2::VPC
+    Properties:
+      CidrBlock: 10.0.0.0/16
+      EnableDnsHostnames: true
+      EnableDnsSupport: true
+      InstanceTenancy: default
+      Tags:
+        - Key: Name
+          Value: rain-build-autoscaling-succeed
+
+  NetworkPublicSubnet1:
+    Type: AWS::EC2::Subnet
+    Properties:
+      AvailabilityZone: !Select
+        - 0
+        - !GetAZs
+          Ref: AWS::Region
+      CidrBlock: 10.0.0.0/18
+      MapPublicIpOnLaunch: true
+      VpcId: !Ref NetworkVPC
+    Metadata:
+      guard:
+        SuppressedRules:
+          - SUBNET_AUTO_ASSIGN_PUBLIC_IP_DISABLED
+
+  NetworkPublicSubnet1RouteTable:
+    Type: AWS::EC2::RouteTable
+    Properties:
+      VpcId: !Ref NetworkVPC
+
+  NetworkPublicSubnet1RouteTableAssociation:
+    Type: AWS::EC2::SubnetRouteTableAssociation
+    Properties:
+      RouteTableId: !Ref NetworkPublicSubnet1RouteTable
+      SubnetId: !Ref NetworkPublicSubnet1
+
+  NetworkPublicSubnet1DefaultRoute:
+    Type: AWS::EC2::Route
+    Properties:
+      DestinationCidrBlock: 0.0.0.0/0
+      GatewayId: !Ref NetworkInternetGateway
+      RouteTableId: !Ref NetworkPublicSubnet1RouteTable
+    Metadata:
+      guard:
+        SuppressedRules:
+          - NO_UNRESTRICTED_ROUTE_TO_IGW
+    DependsOn:
+      - NetworkVPCGW
+
+  NetworkPublicSubnet1EIP:
+    Type: AWS::EC2::EIP
+    Properties:
+      Domain: vpc
+
+  NetworkPublicSubnet1NATGateway:
+    Type: AWS::EC2::NatGateway
+    Properties:
+      AllocationId: !GetAtt NetworkPublicSubnet1EIP.AllocationId
+      SubnetId: !Ref NetworkPublicSubnet1
+    DependsOn:
+      - NetworkPublicSubnet1DefaultRoute
+      - NetworkPublicSubnet1RouteTableAssociation
+
+  NetworkPublicSubnet2:
+    Type: AWS::EC2::Subnet
+    Properties:
+      AvailabilityZone: !Select
+        - 1
+        - !GetAZs
+          Ref: AWS::Region
+      CidrBlock: 10.0.64.0/18
+      MapPublicIpOnLaunch: true
+      VpcId: !Ref NetworkVPC
+    Metadata:
+      guard:
+        SuppressedRules:
+          - SUBNET_AUTO_ASSIGN_PUBLIC_IP_DISABLED
+
+  NetworkPublicSubnet2RouteTable:
+    Type: AWS::EC2::RouteTable
+    Properties:
+      VpcId: !Ref NetworkVPC
+
+  NetworkPublicSubnet2RouteTableAssociation:
+    Type: AWS::EC2::SubnetRouteTableAssociation
+    Properties:
+      RouteTableId: !Ref NetworkPublicSubnet2RouteTable
+      SubnetId: !Ref NetworkPublicSubnet2
+
+  NetworkPublicSubnet2DefaultRoute:
+    Type: AWS::EC2::Route
+    Properties:
+      DestinationCidrBlock: 0.0.0.0/0
+      GatewayId: !Ref NetworkInternetGateway
+      RouteTableId: !Ref NetworkPublicSubnet2RouteTable
+    Metadata:
+      guard:
+        SuppressedRules:
+          - NO_UNRESTRICTED_ROUTE_TO_IGW
+    DependsOn:
+      - NetworkVPCGW
+
+  NetworkPublicSubnet2EIP:
+    Type: AWS::EC2::EIP
+    Properties:
+      Domain: vpc
+
+  NetworkPublicSubnet2NATGateway:
+    Type: AWS::EC2::NatGateway
+    Properties:
+      AllocationId: !GetAtt NetworkPublicSubnet2EIP.AllocationId
+      SubnetId: !Ref NetworkPublicSubnet2
+    DependsOn:
+      - NetworkPublicSubnet2DefaultRoute
+      - NetworkPublicSubnet2RouteTableAssociation
+
+  NetworkPrivateSubnet1Subnet:
+    Type: AWS::EC2::Subnet
+    Properties:
+      AvailabilityZone: !Select
+        - 0
+        - !GetAZs
+          Ref: AWS::Region
+      CidrBlock: 10.0.128.0/18
+      MapPublicIpOnLaunch: false
+      VpcId: !Ref NetworkVPC
+
+  NetworkPrivateSubnet1RouteTable:
+    Type: AWS::EC2::RouteTable
+    Properties:
+      VpcId: !Ref NetworkVPC
+
+  NetworkPrivateSubnet1RouteTableAssociation:
+    Type: AWS::EC2::SubnetRouteTableAssociation
+    Properties:
+      RouteTableId: !Ref NetworkPrivateSubnet1RouteTable
+      SubnetId: !Ref NetworkPrivateSubnet1Subnet
+
+  NetworkPrivateSubnet1DefaultRoute:
+    Type: AWS::EC2::Route
+    Properties:
+      DestinationCidrBlock: 0.0.0.0/0
+      NatGatewayId: !Ref NetworkPublicSubnet1NATGateway
+      RouteTableId: !Ref NetworkPrivateSubnet1RouteTable
+
+  NetworkPrivateSubnet2Subnet:
+    Type: AWS::EC2::Subnet
+    Properties:
+      AvailabilityZone: !Select
+        - 1
+        - !GetAZs
+          Ref: AWS::Region
+      CidrBlock: 10.0.192.0/18
+      MapPublicIpOnLaunch: false
+      VpcId: !Ref NetworkVPC
+
+  NetworkPrivateSubnet2RouteTable:
+    Type: AWS::EC2::RouteTable
+    Properties:
+      VpcId: !Ref NetworkVPC
+
+  NetworkPrivateSubnet2RouteTableAssociation:
+    Type: AWS::EC2::SubnetRouteTableAssociation
+    Properties:
+      RouteTableId: !Ref NetworkPrivateSubnet2RouteTable
+      SubnetId: !Ref NetworkPrivateSubnet2Subnet
+
+  NetworkPrivateSubnet2DefaultRoute:
+    Type: AWS::EC2::Route
+    Properties:
+      DestinationCidrBlock: 0.0.0.0/0
+      NatGatewayId: !Ref NetworkPublicSubnet2NATGateway
+      RouteTableId: !Ref NetworkPrivateSubnet2RouteTable
+
+  NetworkInternetGateway:
+    Type: AWS::EC2::InternetGateway
+
+  NetworkVPCGW:
+    Type: AWS::EC2::VPCGatewayAttachment
+    Properties:
+      InternetGatewayId: !Ref NetworkInternetGateway
+      VpcId: !Ref NetworkVPC
+
+  ALBLoadBalancer:
+    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
+    Properties:
+      LoadBalancerAttributes:
+        - Key: deletion_protection.enabled
+          Value: false
+        - Key: routing.http.drop_invalid_header_fields.enabled
+          Value: true
+      Scheme: internet-facing
+      SecurityGroups:
+        - !GetAtt ALBLoadBalancerSecurityGroup.GroupId
+      Subnets:
+        - !Ref NetworkPublicSubnet1
+        - !Ref NetworkPublicSubnet2
+      Type: application
+
+      # Need these... but can't put them in the module
+      # They will need to be overrides in the parent which is not ideal
+      #DependsOn:
+      #  - PublicSubnet1DefaultRoute
+      #  - PublicSubnet1RouteTableAssociation
+      #  - PublicSubnet2DefaultRoute
+      #  - PublicSubnet2RouteTableAssociation
+    Metadata:
+      checkov:
+        skip:
+          - id: CKV_AWS_91
+      guard:
+        SuppressedRules:
+          - ELB_DELETION_PROTECTION_ENABLED
+    DependsOn:
+      - NetworkPublicSubnet1DefaultRoute
+      - NetworkPublicSubnet1RouteTableAssociation
+      - NetworkPublicSubnet2DefaultRoute
+      - NetworkPublicSubnet2RouteTableAssociation
+
+  ALBLoadBalancerSecurityGroup:
+    Type: AWS::EC2::SecurityGroup
+    Properties:
+      GroupDescription: Automatically created Security Group for ELB
+      SecurityGroupIngress:
+        - CidrIp: 0.0.0.0/0
+          Description: Allow from anyone on port 443
+          FromPort: 443
+          IpProtocol: tcp
+          ToPort: 443
+      VpcId: !Ref NetworkVPC
+
+  ALBLoadBalancerEgress:
+    Type: AWS::EC2::SecurityGroupEgress
+    Properties:
+      Description: Load balancer to target
+      DestinationSecurityGroupId: !GetAtt InstanceSecurityGroup.GroupId
+      FromPort: 80
+      GroupId: !GetAtt ALBLoadBalancerSecurityGroup.GroupId
+      IpProtocol: tcp
+      ToPort: 80
+
+  ALBLoadBalancerListener:
+    Type: AWS::ElasticLoadBalancingV2::Listener
+    Properties:
+      DefaultActions:
+        - TargetGroupArn: !Ref ALBTargetGroup
+          Type: forward
+      LoadBalancerArn: !Ref ALBLoadBalancer
+      Port: 443
+      Protocol: HTTPS
+      Certificates:
+        - CertificateArn: !Ref CertificateArn
+      SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06
+    Metadata:
+      guard:
+        SuppressedRules:
+          - ELBV2_ACM_CERTIFICATE_REQUIRED
+
+  ALBTargetGroup:
+    Type: AWS::ElasticLoadBalancingV2::TargetGroup
+    Properties:
+      Port: 80
+      Protocol: HTTP
+      TargetGroupAttributes:
+        - Key: deregistration_delay.timeout_seconds
+          Value: "10"
+        - Key: stickiness.enabled
+          Value: "false"
+      TargetType: instance
+      VpcId: !Ref NetworkVPC
+
+Outputs:
+  URL:
+    Description: The URL of the website
+    Value: !Join
+      - ""
+      - - https://
+        - !GetAtt ALBLoadBalancer.DNSName
+
diff --git a/test/templates/forecast/ec2-instance-fail.yaml b/test/templates/forecast/ec2-instance-fail.yaml
new file mode 100644
index 00000000..71e0c79b
--- /dev/null
+++ b/test/templates/forecast/ec2-instance-fail.yaml
@@ -0,0 +1,76 @@
+AWSTemplateFormatVersion: "2010-09-09"
+
+Description: |
+  AWS CloudFormation sample template.  
+  Create an Amazon EC2 instance with cfn-init and cfn-signal.
+
+Resources:
+  Instance:
+    Type: AWS::EC2::Instance
+    Metadata:
+      guard:
+        SuppressedRules:
+          - EC2_INSTANCES_IN_VPC
+      AWS::CloudFormation::Init:
+        config:
+          packages:
+            yum:
+              httpd: []
+          files:
+            /var/www/html/index.html:
+              content: |
+                <body>
+                  <h1>Congratulations, you have successfully launched the AWS CloudFormation sample.</h1>
+                </body>
+              mode: "000644"
+              owner: root
+              group: root
+            /etc/cfn/cfn-hup.conf:
+              content: !Sub |
+                [main]
+                stack=${AWS::StackId}
+                region=${AWS::Region}
+              mode: "000400"
+              owner: root
+              group: root
+            /etc/cfn/hooks.d/cfn-auto-reloader.conf:
+              content: !Sub |-
+                [cfn-auto-reloader-hook]
+                triggers=post.update
+                path=Resources.LaunchConfig.Metadata.AWS::CloudFormation::Init
+                action=/opt/aws/bin/cfn-init -v --stack ${AWS::StackName} --resource Instance --region ${AWS::Region}
+                runas=root
+          services:
+            sysvinit:
+              httpd:
+                enabled: true
+                ensureRunning: true
+              cfn-hup:
+                enabled: true
+                ensureRunning: true
+                files:
+                  - /etc/cfn/cfn-hup.conf
+                  - /etc/cfn/hooks.d/cfn-auto-reloader.conf
+    Properties:
+      ImageId: '{{resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64}}'
+      InstanceType: t2.micro
+      KeyName: sample
+      BlockDeviceMappings:
+        - DeviceName: /dev/sda1
+          Ebs:
+            VolumeSize: 32
+      UserData: !Base64
+        Fn::Sub: |-
+          #!/bin/bash
+          /opt/aws/bin/cfn-signal -e $? --stack ${AWS::StackName} --resource WaitHandle --region ${AWS::Region}
+
+  WaitHandle:
+    Type: AWS::CloudFormation::WaitConditionHandle
+
+  WaitCondition:
+    Type: AWS::CloudFormation::WaitCondition
+    DependsOn: Instance
+    Properties:
+      Handle: !Ref WaitHandle
+      Timeout: 300
+      Count: 1
diff --git a/test/templates/forecast/ec2-instance-succeed.yaml b/test/templates/forecast/ec2-instance-succeed.yaml
new file mode 100644
index 00000000..bdc24112
--- /dev/null
+++ b/test/templates/forecast/ec2-instance-succeed.yaml
@@ -0,0 +1,69 @@
+AWSTemplateFormatVersion: "2010-09-09"
+
+Description: |
+  AWS CloudFormation sample template.  
+  Create an Amazon EC2 instance with cfn-init and cfn-signal.
+
+Resources:
+  Instance:
+    Type: AWS::EC2::Instance
+    CreationPolicy:
+      ResourceSignal:
+        Timeout: PT5M
+    Metadata:
+      guard:
+        SuppressedRules:
+          - EC2_INSTANCES_IN_VPC
+      AWS::CloudFormation::Init:
+        config:
+          packages:
+            yum:
+              httpd: []
+          files:
+            /var/www/html/index.html:
+              content: |
+                <body>
+                  <h1>Congratulations, you have successfully launched the AWS CloudFormation sample.</h1>
+                </body>
+              mode: "000644"
+              owner: root
+              group: root
+            /etc/cfn/cfn-hup.conf:
+              content: !Sub |
+                [main]
+                stack=${AWS::StackId}
+                region=${AWS::Region}
+              mode: "000400"
+              owner: root
+              group: root
+            /etc/cfn/hooks.d/cfn-auto-reloader.conf:
+              content: !Sub |-
+                [cfn-auto-reloader-hook]
+                triggers=post.update
+                path=Resources.LaunchConfig.Metadata.AWS::CloudFormation::Init
+                action=/opt/aws/bin/cfn-init -v --stack ${AWS::StackName} --resource Instance --region ${AWS::Region}
+                runas=root
+          services:
+            sysvinit:
+              httpd:
+                enabled: true
+                ensureRunning: true
+              cfn-hup:
+                enabled: true
+                ensureRunning: true
+                files:
+                  - /etc/cfn/cfn-hup.conf
+                  - /etc/cfn/hooks.d/cfn-auto-reloader.conf
+    Properties:
+      ImageId: '{{resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64}}'
+      InstanceType: t4g.nano
+      KeyName: sample
+      BlockDeviceMappings:
+        - DeviceName: /dev/sda1
+          Ebs:
+            VolumeSize: 32
+      UserData: !Base64
+        Fn::Sub: |-
+          #!/bin/bash
+          /opt/aws/bin/cfn-signal -e $? --stack ${AWS::StackName} --resource Instance --region ${AWS::Region}
+
diff --git a/test/templates/forecast/lambda-fail.yaml b/test/templates/forecast/lambda-fail.yaml
new file mode 100644
index 00000000..7c82a902
--- /dev/null
+++ b/test/templates/forecast/lambda-fail.yaml
@@ -0,0 +1,41 @@
+AWSTemplateFormatVersion: 2010-09-09
+
+Description: |
+  This template creates a lambda function that fails to deploy because it 
+  refers to a hard-coded arn that does not exist, and a second lambda function
+  that fails to deploy because it refers to a role lambda can't assume.
+  
+
+Resources:
+
+  LambdaFunction:
+    Type: AWS::Lambda::Function
+    Properties:
+      FunctionName: NonexistentRoleArn
+      Handler: index.handler
+      Role: "arn:aws:iam::123456789012:role/DoesNotExist"
+      Code:
+        ZipFile: |
+          exports.handler = async (event) => {
+            console.log(event);
+            return 'Hello from Lambda!';
+          };
+      Runtime: nodejs20.x
+      Timeout: 30
+      MemorySize: 128
+
+  LambdaFunction2:
+    Type: AWS::Lambda::Function
+    Properties:
+      FunctionName: InvalidRoleArn
+      Handler: index.handler
+      Role: "arn:aws:iam::755952356119:role/cep-alpha-awscommunity-CloudFrontLoggingEnabledBui-18841URX8LHC6"
+      Code:
+        ZipFile: |
+          exports.handler = async (event) => {
+            console.log(event);
+            return 'Hello from Lambda!';
+          };
+      Runtime: nodejs20.x
+      Timeout: 30
+      MemorySize: 128
diff --git a/test/templates/forecast/lambda-succeed.yaml b/test/templates/forecast/lambda-succeed.yaml
new file mode 100644
index 00000000..5984beb4
--- /dev/null
+++ b/test/templates/forecast/lambda-succeed.yaml
@@ -0,0 +1,22 @@
+AWSTemplateFormatVersion: 2010-09-09
+
+Description: |
+  This template creates a lambda function that successfully deploys
+
+Resources:
+
+  LambdaFunction:
+    Type: AWS::Lambda::Function
+    Properties:
+      FunctionName: Rain-Forecast-RoleIsValid
+      Handler: index.handler
+      Role: "arn:aws:iam::755952356119:role/lambda-basic"
+      Code:
+        ZipFile: |
+          exports.handler = async (event) => {
+            console.log(event);
+            return 'Hello from Lambda!';
+          };
+      Runtime: nodejs20.x
+      Timeout: 30
+      MemorySize: 128