Repository: aws-cloudformation/rain
Pull request #500 (2024-08-19 21:21:43)

Forecast plugins
This PR adds the ability to build a plugin for the `forecast` command. The plugin is a separately compiled `.so` file that has its own prediction functions. They can be added to the default forecasters, or replace them entirely. This can be useful for users who want to make additional checks against internal systems to validate the template before deployment.


Patch:
diff --git a/.gitignore b/.gitignore
index 91fb9f80..06ba2ad8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,4 +32,6 @@ local/
 .env
 .out
 
-.DS_Store
\ No newline at end of file
+.DS_Store
+plugin.so
+
diff --git a/cmd/sample_plugin/main.go b/cmd/sample_plugin/main.go
new file mode 100644
index 00000000..f3b3f096
--- /dev/null
+++ b/cmd/sample_plugin/main.go
@@ -0,0 +1,34 @@
+package main
+
+import (
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
+)
+
+type PluginImpl struct{}
+
+// A sample prediction function.
+func predictLambda(input fc.PredictionInput) fc.Forecast {
+	forecast := fc.MakeForecast(&input)
+
+	// Implement whatever checks you want to make here
+	forecast.Add("CODE", false, "testing plugin", 0)
+
+	return forecast
+}
+
+// GetForecasters must be implemented by forecast plugins
+// This function returns all predictions functions implemented by the plugin
+func (p *PluginImpl) GetForecasters() map[string]func(input fc.PredictionInput) fc.Forecast {
+	retval := make(map[string]func(input fc.PredictionInput) fc.Forecast)
+
+	retval["AWS::Lambda::Function"] = predictLambda
+
+	return retval
+}
+
+// Leave main empty
+func main() {
+}
+
+// This variable is required to allow rain to find the implementation
+var Plugin = PluginImpl{}
diff --git a/internal/aws/cfn/cfn.go b/internal/aws/cfn/cfn.go
index 7efeabd7..93dde7ed 100644
--- a/internal/aws/cfn/cfn.go
+++ b/internal/aws/cfn/cfn.go
@@ -20,6 +20,7 @@ import (
 	"github.com/aws-cloudformation/rain/internal/config"
 	"github.com/aws-cloudformation/rain/internal/dc"
 	"github.com/aws-cloudformation/rain/internal/s11n"
+	"github.com/aws-cloudformation/rain/plugins/deployconfig"
 	"github.com/aws/aws-sdk-go-v2/service/cloudformation"
 	"github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
 	"github.com/aws/smithy-go/ptr"
@@ -732,7 +733,7 @@ func GetPrimaryIdentifierValues(
 	primaryIdentifier []string,
 	resource *yaml.Node,
 	template *yaml.Node,
-	dc *dc.DeployConfig) []string {
+	dc *deployconfig.DeployConfig) []string {
 
 	piValues := make([]string, 0)
 
@@ -779,7 +780,7 @@ func GetPrimaryIdentifierValues(
 // TODO: Is this dead code now? We resolve refs early in forecast.
 //
 //	What else uses this?
-func resolveRef(name string, template *yaml.Node, dc *dc.DeployConfig) (string, error) {
+func resolveRef(name string, template *yaml.Node, dc *deployconfig.DeployConfig) (string, error) {
 	_, params, _ := s11n.GetMapValue(template.Content[0], "Parameters")
 	if params != nil {
 		for i, param := range params.Content {
@@ -810,7 +811,7 @@ func ResourceAlreadyExists(
 	resource *yaml.Node,
 	stackExists bool,
 	template *yaml.Node,
-	dc *dc.DeployConfig) bool {
+	dc *deployconfig.DeployConfig) bool {
 
 	if !stackExists {
 		primaryIdentifiers, err := GetTypeIdentifier(typeName)
diff --git a/internal/cmd/cc/cc.go b/internal/cmd/cc/cc.go
index 1e0cddc6..91bff84a 100644
--- a/internal/cmd/cc/cc.go
+++ b/internal/cmd/cc/cc.go
@@ -4,7 +4,7 @@ import (
 	"github.com/aws-cloudformation/rain/cft"
 	"github.com/aws-cloudformation/rain/internal/aws/s3"
 	"github.com/aws-cloudformation/rain/internal/config"
-	"github.com/aws-cloudformation/rain/internal/dc"
+	"github.com/aws-cloudformation/rain/plugins/deployconfig"
 	"github.com/spf13/cobra"
 )
 
@@ -20,7 +20,7 @@ var unlock string
 // Globals (seems bad..? but cumbersome to pass them around)
 var deployedTemplate cft.Template
 var resMap map[string]*Resource
-var templateConfig *dc.DeployConfig
+var templateConfig *deployconfig.DeployConfig
 
 var Cmd = &cobra.Command{
 	Use:   "cc <command>",
diff --git a/internal/cmd/forecast/README.md b/internal/cmd/forecast/README.md
index 69da3806..788c1c98 100644
--- a/internal/cmd/forecast/README.md
+++ b/internal/cmd/forecast/README.md
@@ -63,6 +63,51 @@ These can be ignored with the `--ignore` argument.
 The forecast command also tries to estimate how long it thinks your stack will
 take to deploy.
 
+## Plugins
+
+You can build a plugin that runs prediction functions that you write yourself.
+This can be useful if you have internal systems that you want to check to make 
+sure the template is valid before deployment. You can see an example plugin in 
+`cmd/sample_plugin/main.go`.
+
+```go
+package main
+
+import (
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
+)
+
+type PluginImpl struct{}
+
+// A sample prediction function.
+func predictLambda(input fc.PredictionInput) fc.Forecast {
+	forecast := fc.MakeForecast(&input)
+
+	// Implement whatever checks you want to make here
+	forecast.Add("CODE", false, "testing plugin", 0)
+
+	return forecast
+}
+
+// GetForecasters must be implemented by forecast plugins
+// This function returns all predictions functions implemented by the plugin
+func (p *PluginImpl) GetForecasters() map[string]func(input fc.PredictionInput) fc.Forecast {
+	retval := make(map[string]func(input fc.PredictionInput) fc.Forecast)
+
+	retval["AWS::Lambda::Function"] = predictLambda
+
+	return retval
+}
+
+// Leave main empty
+func main() {
+}
+
+// This variable is required to allow rain to find the implementation
+var Plugin = PluginImpl{}
+
+```
+
 ## Roadmap
 
 You can view the issues list for the forecast command
diff --git a/internal/cmd/forecast/arns.go b/internal/cmd/forecast/arns.go
index d4209511..1211cb12 100644
--- a/internal/cmd/forecast/arns.go
+++ b/internal/cmd/forecast/arns.go
@@ -7,6 +7,7 @@ import (
 	"github.com/aws-cloudformation/rain/internal/config"
 	"github.com/aws-cloudformation/rain/internal/node"
 	"github.com/aws-cloudformation/rain/internal/s11n"
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
 	"github.com/google/uuid"
 )
 
@@ -14,7 +15,7 @@ import (
 // Returns "" if we don't know how to make the arn, we haven't implemented it yet, or if
 // the resource does not have Arns that we can use to plug in to the simulator.
 // Supported services: S3, EC2
-func predictResourceArn(input PredictionInput) string {
+func predictResourceArn(input fc.PredictionInput) string {
 
 	// There's not a great way to do this in a truly generic fashion.
 	// There is no direct map between IAM resource ARNs and CloudFormation resource types.
@@ -25,9 +26,9 @@ func predictResourceArn(input PredictionInput) string {
 	// Make up an id if it doesn't exist yet
 	physicalId := fmt.Sprintf("rain-%v", uuid.New())
 
-	if input.stackExists {
+	if input.StackExists {
 
-		res, err := cfn.GetStackResource(input.stackName, input.logicalId)
+		res, err := cfn.GetStackResource(input.StackName, input.LogicalId)
 		if err == nil {
 			// The resource exists
 			physicalId = *res.PhysicalResourceId
@@ -43,7 +44,7 @@ func predictResourceArn(input PredictionInput) string {
 	// There might be restrictions on the format of a physical id, so
 	// we might have to change it per resource type.
 
-	// Some resources have complex arns that will require us to inspect input.resource
+	// Some resources have complex arns that will require us to inspect input.Resource
 
 	// To add new resources here, a good way to start is by opening both the CloudFormation
 	// docs for the service, and the Service Authorization Reference (Resource Types section)
@@ -54,7 +55,7 @@ func predictResourceArn(input PredictionInput) string {
 	// and
 	// https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazons3.html#amazons3-resources-for-iam-policies
 
-	switch input.typeName {
+	switch input.TypeName {
 	case "AWS::S3::Bucket":
 		return fmt.Sprintf("arn:aws:s3:::%v", physicalId)
 	case "AWS::S3::BucketPolicy":
@@ -62,11 +63,11 @@ func predictResourceArn(input PredictionInput) string {
 	case "AWS::S3::AccessPoint":
 		// arn:${Partition}:s3:${Region}:${Account}:accesspoint/${AccessPointName}
 		return fmt.Sprintf("arn:%v:s3:%v:%v:accesspoint/%v",
-			input.env.partition, input.env.region, input.env.account, physicalId)
+			input.Env.Partition, input.Env.Region, input.Env.Account, physicalId)
 	case "AWS::S3::MultiRegionAccessPoint":
 		// arn:${Partition}:s3::${Account}:accesspoint/${AccessPointAlias}
 		return fmt.Sprintf("arn:%v:s3::%v:accesspoint/%v",
-			input.env.partition, input.env.account, physicalId)
+			input.Env.Partition, input.Env.Account, physicalId)
 	case "AWS::S3::MultiRegionAccessPointPolicy":
 		return ""
 	case "AWS::S3::StorageLens":
@@ -310,22 +311,22 @@ func predictResourceArn(input PredictionInput) string {
 	case "AWS::Lambda::Alias":
 		// arn:${Partition}:lambda:${Region}:${Account}:function:${FunctionName}:${Alias}
 		// Get the function name and alias from the template
-		_, props, _ := s11n.GetMapValue(input.resource, "Properties")
+		_, props, _ := s11n.GetMapValue(input.Resource, "Properties")
 		if props == nil {
 			config.Debugf("unexpected, AWS::Lambda::Alias props are nil: %v",
-				node.ToJson(input.resource))
+				node.ToJson(input.Resource))
 			return ""
 		}
 		_, functionName, _ := s11n.GetMapValue(props, "FunctionName")
 		if functionName == nil {
 			config.Debugf("unexpected, AWS::Lambda::Alias FunctionName is nil: %v",
-				node.ToJson(input.resource))
+				node.ToJson(input.Resource))
 			return ""
 		}
 		_, name, _ := s11n.GetMapValue(props, "Name")
 		if name == nil {
 			config.Debugf("unexpected, AWS::Lambda::Alias Name is nil: %v",
-				node.ToJson(input.resource))
+				node.ToJson(input.Resource))
 			return ""
 		}
 		fn := functionName.Value
@@ -336,7 +337,7 @@ func predictResourceArn(input PredictionInput) string {
 			}
 		}
 		return fmt.Sprintf("arn:%v:lambda:%v:%v:function:%v:%v",
-			input.env.partition, input.env.region, input.env.account,
+			input.Env.Partition, input.Env.Region, input.Env.Account,
 			fn, name.Value)
 	case "AWS::Lambda::CodeSigningConfig":
 		// arn:${Partition}:lambda:${Region}:${Account}:code-signing-config:${CodeSigningConfigId}
@@ -351,10 +352,10 @@ func predictResourceArn(input PredictionInput) string {
 		return colonFormat("lambda", "function", input, physicalId)
 	case "AWS::Lambda::LayerVersion":
 		// arn:${Partition}:lambda:${Region}:${Account}:layer:${LayerName}:${LayerVersion}
-		_, props, _ := s11n.GetMapValue(input.resource, "Properties")
+		_, props, _ := s11n.GetMapValue(input.Resource, "Properties")
 		if props == nil {
 			config.Debugf("unexpected, AWS::Lambda::Alias props are nil: %v",
-				node.ToJson(input.resource))
+				node.ToJson(input.Resource))
 			return ""
 		}
 		_, layerNameProp, _ := s11n.GetMapValue(props, "LayerName")
@@ -369,7 +370,7 @@ func predictResourceArn(input PredictionInput) string {
 			}
 		}
 		return fmt.Sprintf("arn:%v:lambda:%v:%v:layer:%v:%v",
-			input.env.partition, input.env.region, input.env.account,
+			input.Env.Partition, input.Env.Region, input.Env.Account,
 			layerName, "1")
 	case "AWS::Lambda::LayerVersionPermission":
 		return ""
@@ -379,16 +380,16 @@ func predictResourceArn(input PredictionInput) string {
 		return ""
 	case "AWS::Lambda::Version":
 		// arn:${Partition}:lambda:${Region}:${Account}:function:${FunctionName}:${Version}
-		_, props, _ := s11n.GetMapValue(input.resource, "Properties")
+		_, props, _ := s11n.GetMapValue(input.Resource, "Properties")
 		if props == nil {
 			config.Debugf("unexpected, AWS::Lambda::Version props are nil: %v",
-				node.ToJson(input.resource))
+				node.ToJson(input.Resource))
 			return ""
 		}
 		_, functionName, _ := s11n.GetMapValue(props, "FunctionName")
 		if functionName == nil {
 			config.Debugf("unexpected, AWS::Lambda::Version FunctionName is nil: %v",
-				node.ToJson(input.resource))
+				node.ToJson(input.Resource))
 			return ""
 		}
 		fn := functionName.Value
@@ -399,7 +400,7 @@ func predictResourceArn(input PredictionInput) string {
 			}
 		}
 		return fmt.Sprintf("arn:%v:lambda:%v:%v:function:%v:%v",
-			input.env.partition, input.env.region, input.env.account,
+			input.Env.Partition, input.Env.Region, input.Env.Account,
 			fn, "1")
 	case "AWS::IAM::AccessKey":
 		return ""
@@ -452,23 +453,23 @@ func predictResourceArn(input PredictionInput) string {
 // standardFormat returns an arn that fits the standard arn format.
 // for example
 // arn:${Partition}:ec2:${Region}:${Account}:capacity-reservation/${CapacityReservationId}
-func standardFormat(service string, subType string, input PredictionInput, physicalId string) string {
+func standardFormat(service string, subType string, input fc.PredictionInput, physicalId string) string {
 	return fmt.Sprintf("arn:%v:%v:%v:%v:%v/%v",
-		input.env.partition, service, input.env.region, input.env.account, subType, physicalId)
+		input.Env.Partition, service, input.Env.Region, input.Env.Account, subType, physicalId)
 }
 
 // colonFormat returns an arn that fits the (other) standard arn format.
 // for example
 // arn:${Partition}:ec2:${Region}:${Account}:capacity-reservation:${CapacityReservationId}
-func colonFormat(service string, subType string, input PredictionInput, physicalId string) string {
+func colonFormat(service string, subType string, input fc.PredictionInput, physicalId string) string {
 	return fmt.Sprintf("arn:%v:%v:%v:%v:%v:%v",
-		input.env.partition, service, input.env.region, input.env.account, subType, physicalId)
+		input.Env.Partition, service, input.Env.Region, input.Env.Account, subType, physicalId)
 }
 
 // GlobalFormat returns an arn that fits IAM and other non-regional services
 // for example
 // arn:${Partition}:iam::${Account}:group/${GroupNameWithPath}
-func globalFormat(service string, subType string, input PredictionInput, physicalId string) string {
+func globalFormat(service string, subType string, input fc.PredictionInput, physicalId string) string {
 	return fmt.Sprintf("arn:%v:%v::%v:%v:%v",
-		input.env.partition, service, input.env.account, subType, physicalId)
+		input.Env.Partition, service, input.Env.Account, subType, physicalId)
 }
diff --git a/internal/cmd/forecast/ec2_instance.go b/internal/cmd/forecast/ec2_instance.go
index 04cbfcd9..788e3a28 100644
--- a/internal/cmd/forecast/ec2_instance.go
+++ b/internal/cmd/forecast/ec2_instance.go
@@ -10,10 +10,11 @@ import (
 	"github.com/aws-cloudformation/rain/internal/config"
 	"github.com/aws-cloudformation/rain/internal/console/spinner"
 	"github.com/aws-cloudformation/rain/internal/s11n"
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
 	"gopkg.in/yaml.v3"
 )
 
-func checkKeyName(input *PredictionInput, forecast *Forecast) {
+func checkKeyName(input *fc.PredictionInput, forecast *fc.Forecast) {
 
 	var keyName string
 
@@ -31,26 +32,26 @@ func checkKeyName(input *PredictionInput, forecast *Forecast) {
 			keyName = keyNameProp.Value
 		} else {
 			// We resolved Refs earlier so it should be a string
-			config.Debugf("%s.KeyName is not a string", input.logicalId)
+			config.Debugf("%s.KeyName is not a string", input.LogicalId)
 			return
 		}
 
 		if keyName != "" {
 
 			// Check to see if the key exists
-			spin(input.typeName, input.logicalId, "EC2 instance key exists?")
+			spin(input.TypeName, input.LogicalId, "EC2 instance key exists?")
 
 			exists, _ := ec2.CheckKeyPairExists(keyName)
 			code := F0007
 			if exists {
-				forecast.Add(code, true, "Key exists")
+				forecast.Add(code, true, "Key exists", input.Resource.Line)
 			} else {
-				forecast.Add(code, false, "Key does not exist")
+				forecast.Add(code, false, "Key does not exist", input.Resource.Line)
 			}
 
 			spinner.Pop()
 		} else {
-			config.Debugf("%s.KeyName is empty", input.logicalId)
+			config.Debugf("%s.KeyName is empty", input.LogicalId)
 		}
 	}
 }
@@ -85,7 +86,7 @@ func resolveImageId(imageId string) string {
 }
 
 // checkInstanceType checks to see if the AMI and the instance type are compatible
-func checkInstanceType(input *PredictionInput, forecast *Forecast) {
+func checkInstanceType(input *fc.PredictionInput, forecast *fc.Forecast) {
 
 	var instanceType string
 	code := F0008
@@ -97,7 +98,7 @@ func checkInstanceType(input *PredictionInput, forecast *Forecast) {
 
 	_, instanceTypeProp, _ := s11n.GetMapValue(props, "InstanceType")
 	if instanceTypeProp == nil {
-		config.Debugf("%s does not have InstanceType", input.logicalId)
+		config.Debugf("%s does not have InstanceType", input.LogicalId)
 		return
 	}
 
@@ -107,20 +108,21 @@ func checkInstanceType(input *PredictionInput, forecast *Forecast) {
 		instanceType = instanceTypeProp.Value
 	} else {
 		// We resolved Refs earlier so it should be a string
-		config.Debugf("%s.InstanceType is not a string", input.logicalId)
+		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?")
+	spin(input.TypeName, input.LogicalId, "EC2 instance type exists?")
 	instanceTypeInfo, err := ec2.GetInstanceType(instanceType)
 	if err != nil {
 		config.Debugf("GetInstanceType %s: %v", instanceType, err)
-		forecast.Add(code, false, fmt.Sprintf("Instance type does not exist: %s", instanceType))
+		forecast.Add(code, false, fmt.Sprintf("Instance type does not exist: %s", instanceType),
+			input.Resource.Line)
 		spinner.Pop()
 		return
 	} else {
-		forecast.Add(code, true, "Instance type exists")
+		forecast.Add(code, true, "Instance type exists", input.Resource.Line)
 	}
 	spinner.Pop()
 
@@ -130,21 +132,22 @@ func checkInstanceType(input *PredictionInput, forecast *Forecast) {
 
 	_, imageIdNode, _ := s11n.GetMapValue(props, "ImageId")
 	if imageIdNode == nil {
-		config.Debugf("%s does not have ImageId", input.logicalId)
+		config.Debugf("%s does not have ImageId", input.LogicalId)
 		return
 	}
 
 	imageId := resolveImageId(imageIdNode.Value)
 
-	spin(input.typeName, input.logicalId, "EC2 instance type matches AMI?")
+	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))
+		forecast.Add(F0009, false, fmt.Sprintf("Image not found: %s", imageId),
+			input.Resource.Line)
 		spinner.Pop()
 		return
 	}
 
-	config.Debugf("Image for %s: %+v", input.logicalId, image)
+	config.Debugf("Image for %s: %+v", input.LogicalId, image)
 
 	instanceTypesForArch, err := ec2.GetInstanceTypesForArchitecture(string(image.Architecture))
 	if err != nil {
@@ -157,35 +160,36 @@ func checkInstanceType(input *PredictionInput, forecast *Forecast) {
 	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))
+			fmt.Sprintf("Instance type %s does not support AMI %s", instanceType, imageId),
+			input.Resource.Line)
 	} else {
-		forecast.Add(code, true, "Instance type matches AMI")
+		forecast.Add(code, true, "Instance type matches AMI", input.Resource.Line)
 	}
 	spinner.Pop()
 }
 
-func getPropNode(input *PredictionInput) *yaml.Node {
+func getPropNode(input *fc.PredictionInput) *yaml.Node {
 	// Check to see if the resource has the InstanceType property set
-	_, props, _ := s11n.GetMapValue(input.resource, "Properties")
+	_, props, _ := s11n.GetMapValue(input.Resource, "Properties")
 	if props == nil {
-		config.Debugf("expected %s to have Properties", input.logicalId)
+		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" {
+	// 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)
+			config.Debugf("expected %s to have LaunchTemplateData", input.LogicalId)
 			return nil
 		}
 	}
 	return props
 }
 
-func CheckEC2Instance(input PredictionInput) Forecast {
+func CheckEC2Instance(input fc.PredictionInput) fc.Forecast {
 
-	forecast := makeForecast(input.typeName, input.logicalId)
+	forecast := fc.MakeForecast(&input)
 
 	// Check to see if the key name exists
 	checkKeyName(&input, &forecast)
diff --git a/internal/cmd/forecast/ec2_launchtemplate.go b/internal/cmd/forecast/ec2_launchtemplate.go
index 91c3bef7..1b273599 100644
--- a/internal/cmd/forecast/ec2_launchtemplate.go
+++ b/internal/cmd/forecast/ec2_launchtemplate.go
@@ -1,10 +1,12 @@
 package forecast
 
+import fc "github.com/aws-cloudformation/rain/plugins/forecast"
+
 // AWS::EC2::LaunchTemplate
 
-func CheckEC2LaunchTemplate(input PredictionInput) Forecast {
+func CheckEC2LaunchTemplate(input fc.PredictionInput) fc.Forecast {
 
-	forecast := makeForecast(input.typeName, input.logicalId)
+	forecast := fc.MakeForecast(&input)
 
 	// Check to see if the key name exists
 	checkKeyName(&input, &forecast)
diff --git a/internal/cmd/forecast/ec2_securitygroup.go b/internal/cmd/forecast/ec2_securitygroup.go
index 0548a26f..537af937 100644
--- a/internal/cmd/forecast/ec2_securitygroup.go
+++ b/internal/cmd/forecast/ec2_securitygroup.go
@@ -8,10 +8,11 @@ import (
 	"github.com/aws-cloudformation/rain/internal/config"
 	"github.com/aws-cloudformation/rain/internal/console/spinner"
 	"github.com/aws-cloudformation/rain/internal/s11n"
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
 	"gopkg.in/yaml.v3"
 )
 
-func CheckEC2SecurityGroup(input PredictionInput) Forecast {
+func CheckEC2SecurityGroup(input fc.PredictionInput) fc.Forecast {
 
 	// TODO - Cannot delete a security group that still has instances
 	// (See if there are instances not in this stack)
@@ -28,9 +29,9 @@ func CheckEC2SecurityGroup(input PredictionInput) Forecast {
 		SourceSecurityGroupId property and specify the security group ID.
 	*/
 
-	forecast := makeForecast(input.typeName, input.logicalId)
+	forecast := fc.MakeForecast(&input)
 
-	spin(input.typeName, input.logicalId, "Checking if all security groups are in the same VPC")
+	spin(input.TypeName, input.LogicalId, "Checking if all security groups are in the same VPC")
 	checkSecurityGroupsInSameVPC(&input, &forecast)
 	spinner.Pop()
 
@@ -62,7 +63,7 @@ func CheckEC2SecurityGroup(input PredictionInput) Forecast {
 //	}
 //}
 
-func checkSecurityGroupsInSameVPC(input *PredictionInput, forecast *Forecast) {
+func checkSecurityGroupsInSameVPC(input *fc.PredictionInput, forecast *fc.Forecast) {
 
 	code := F0010
 
@@ -70,13 +71,13 @@ func checkSecurityGroupsInSameVPC(input *PredictionInput, forecast *Forecast) {
 	defaultVPCId, err := ec2.GetDefaultVPCId()
 	if err != nil {
 		msg := fmt.Sprintf("Unable to get default VPC Id: %v", err)
-		forecast.Add(code, false, msg)
+		forecast.Add(code, false, msg, input.Resource.Line)
 		return
 	}
 
 	config.Debugf("defaultVPCId: %s", defaultVPCId)
 
-	resources, err := input.source.GetSection(cft.Resources)
+	resources, err := input.Source.GetSection(cft.Resources)
 	if err != nil {
 		config.Debugf("Unable to get Resources: %v", err)
 		return
@@ -118,14 +119,14 @@ func checkSecurityGroupsInSameVPC(input *PredictionInput, forecast *Forecast) {
 			}
 			if resourceVpcId == "" {
 				// Not sure what this is
-				config.Debugf("Unable to determine VpcId for %s", input.logicalId)
+				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)
+				forecast.Add(F0011, false, msg, input.Resource.Line)
 			}
 		}
 
@@ -133,10 +134,11 @@ func checkSecurityGroupsInSameVPC(input *PredictionInput, forecast *Forecast) {
 			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)
+			forecast.Add(code, false, msg, input.Resource.Line)
 			return
 		}
 
 	}
-	forecast.Add(code, true, "All VPC Ids on security groups are the same")
+	forecast.Add(code, true, "All VPC Ids on security groups are the same",
+		input.Resource.Line)
 }
diff --git a/internal/cmd/forecast/elb.go b/internal/cmd/forecast/elb.go
index b3b37e13..922f2aa0 100644
--- a/internal/cmd/forecast/elb.go
+++ b/internal/cmd/forecast/elb.go
@@ -6,6 +6,7 @@ import (
 	"github.com/aws-cloudformation/rain/internal/console/spinner"
 	"github.com/aws-cloudformation/rain/internal/node"
 	"github.com/aws-cloudformation/rain/internal/s11n"
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
 	"gopkg.in/yaml.v3"
 )
 
@@ -13,9 +14,9 @@ import (
 
 // AWS::ElasticLoadBalancingV2::Listener
 
-func CheckELBListener(input PredictionInput) Forecast {
+func CheckELBListener(input fc.PredictionInput) fc.Forecast {
 
-	forecast := makeForecast(input.typeName, input.logicalId)
+	forecast := fc.MakeForecast(&input)
 
 	// Resource handler returned message:
 	// "Certificate 'arn:aws:acm:us-east-1:X:certificate/Y'
@@ -23,20 +24,20 @@ func CheckELBListener(input PredictionInput) Forecast {
 	// (Expired certs cause this error)
 
 	// Get the certificate arn from props
-	_, props, _ := s11n.GetMapValue(input.resource, "Properties")
+	_, props, _ := s11n.GetMapValue(input.Resource, "Properties")
 	if props == nil {
-		config.Debugf("expected %s to have Properties", input.logicalId)
+		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)
+		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")
+	spin(input.TypeName, input.LogicalId, "Checking ELB Certs")
 
 	for _, certArnNode := range certArns.Content {
 		if len(certArnNode.Content) != 2 {
@@ -53,9 +54,10 @@ func CheckELBListener(input PredictionInput) Forecast {
 		}
 		code := F0012
 		if !ok {
-			forecast.Add(code, false, "Certificate not found or expired")
+			forecast.Add(code, false, "Certificate not found or expired",
+				input.Resource.Line)
 		} else {
-			forecast.Add(code, true, "Certificate found")
+			forecast.Add(code, true, "Certificate found", input.Resource.Line)
 		}
 	}
 
@@ -64,9 +66,9 @@ func CheckELBListener(input PredictionInput) Forecast {
 	return forecast
 }
 
-func CheckELBTargetGroup(input PredictionInput) Forecast {
+func CheckELBTargetGroup(input fc.PredictionInput) fc.Forecast {
 
-	forecast := makeForecast(input.typeName, input.logicalId)
+	forecast := fc.MakeForecast(&input)
 
 	// Check to make sure the Port and Protocol properties match
 	portNode := input.GetPropertyNode("Port")
@@ -76,16 +78,20 @@ func CheckELBTargetGroup(input PredictionInput) Forecast {
 		protocol := protocolNode.Value
 		if port == "443" {
 			if protocol == "HTTPS" {
-				forecast.Add(F0014, true, "ELB target group port and protocol match")
+				forecast.Add(F0014, true, "ELB target group port and protocol match",
+					input.Resource.Line)
 			} else {
-				forecast.Add(F0014, false, "ELB target group port and protocol do not match")
+				forecast.Add(F0014, false, "ELB target group port and protocol do not match",
+					input.Resource.Line)
 			}
 		}
 		if port == "80" {
 			if protocol == "HTTP" {
-				forecast.Add(F0014, true, "ELB target group port and protocol match")
+				forecast.Add(F0014, true, "ELB target group port and protocol match",
+					input.Resource.Line)
 			} else {
-				forecast.Add(F0014, false, "ELB target group port and protocol do not match")
+				forecast.Add(F0014, false, "ELB target group port and protocol do not match",
+					input.Resource.Line)
 			}
 		}
 	}
@@ -96,7 +102,7 @@ func CheckELBTargetGroup(input PredictionInput) Forecast {
 		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")
+			autoscalingGroups := input.Source.GetResourcesOfType("AWS::AutoScaling::AutoScalingGroup")
 			for _, asg := range autoscalingGroups {
 				_, props, _ := s11n.GetMapValue(asg, "Properties")
 				if props == nil {
@@ -111,12 +117,14 @@ func CheckELBTargetGroup(input PredictionInput) Forecast {
 					if targetGroupArn.Kind == yaml.MappingNode {
 						if targetGroupArn.Content[0].Kind == yaml.ScalarNode &&
 							targetGroupArn.Content[0].Value == "Ref" {
-							if targetGroupArn.Content[1].Value == input.logicalId {
+							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")
+									"ELB target group must be of type instance if it is used by an ASG",
+									input.Resource.Line)
 							} else {
 								forecast.Add(F0015, true,
-									"ELB target group is of type instance if it is used by an ASG")
+									"ELB target group is of type instance if it is used by an ASG",
+									input.Resource.Line)
 							}
 						}
 					}
diff --git a/internal/cmd/forecast/forecast.go b/internal/cmd/forecast/forecast.go
index 41f49038..626ec6b1 100644
--- a/internal/cmd/forecast/forecast.go
+++ b/internal/cmd/forecast/forecast.go
@@ -6,7 +6,7 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
-	"slices"
+	"plugin"
 	"strings"
 
 	"github.com/aws-cloudformation/rain/cft"
@@ -22,6 +22,8 @@ import (
 	"github.com/aws-cloudformation/rain/internal/console/spinner"
 	"github.com/aws-cloudformation/rain/internal/dc"
 	"github.com/aws-cloudformation/rain/internal/s11n"
+	"github.com/aws-cloudformation/rain/plugins/deployconfig"
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
 	"github.com/spf13/cobra"
 	"gopkg.in/yaml.v3"
 
@@ -55,8 +57,11 @@ var all bool
 // Which stack action to check (--action)
 var action string
 
-// Which checks to ignore (--ignore)
-var ignore []string
+// Path to a plugin .so file
+var pluginPath string
+
+// Only run predictions from the plugin, don't run any of the built in checks
+var pluginOnly bool
 
 const (
 	ALL    = "all"
@@ -65,119 +70,17 @@ const (
 	DELETE = "delete"
 )
 
-type Env struct {
-	partition string
-	region    string
-	account   string
-}
-
-// PredictionInput is the input to forecast prediction functions
-type PredictionInput struct {
-	source      cft.Template
-	stackName   string
-	resource    *yaml.Node
-	logicalId   string
-	stackExists bool
-	stack       types.Stack
-	typeName    string
-	dc          *dc.DeployConfig
-	env         Env
-	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
-}
-
 // GetNode is a simplified version of s11n.GetMapValue that returns the value only
 func GetNode(prop *yaml.Node, name string) *yaml.Node {
 	_, n, _ := s11n.GetMapValue(prop, name)
 	return n
 }
 
-// LineNumber is the current line number in the template
-var LineNumber int
-
-// Forecast represents predictions for a single resource in the template
-type Forecast struct {
-	TypeName  string
-	LogicalId string
-	Passed    []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 {
-	return len(f.Passed) + len(f.Failed)
-}
-
-func (f *Forecast) GetNumFailed() int {
-	return len(f.Failed)
-}
-
-func (f *Forecast) GetNumPassed() int {
-	return len(f.Passed)
-}
-
-func (f *Forecast) Append(forecast Forecast) {
-	f.Failed = append(f.Failed, forecast.Failed...)
-	f.Passed = append(f.Passed, forecast.Passed...)
-}
-
-// Add adds a pass or fail message, formatting it to include the type name and logical id
-func (f *Forecast) Add(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, check)
-	} else {
-		f.Failed = append(f.Failed, check)
-	}
-}
-
-func (c *Check) String() string {
-	var passFail string
-	if c.Pass {
-		passFail = "PASS"
-	} else {
-		passFail = "FAIL"
-	}
-	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([]Check, 0),
-		Failed:    make([]Check, 0),
-	}
-}
-
 // forecasters is a map of resource type names to prediction functions.
-var forecasters = make(map[string]func(input PredictionInput) Forecast)
+var forecasters = make(map[string]func(input fc.PredictionInput) fc.Forecast)
+
+// pluginForecasters are extra forecasters loaded from a .so
+var pluginForecasters = make(map[string]func(input fc.PredictionInput) fc.Forecast)
 
 // Push a message about checking a resource onto the spinner
 func spin(typeName string, logicalId string, message string) {
@@ -185,14 +88,14 @@ func spin(typeName string, logicalId string, message string) {
 }
 
 // Run all forecasters for the type
-func forecastForType(input PredictionInput) Forecast {
+func forecastForType(input fc.PredictionInput) fc.Forecast {
 
-	forecast := makeForecast(input.typeName, input.logicalId)
+	forecast := fc.MakeForecast(&input)
 
 	// Only run the forecaster if it matches the optional --type arg,
 	// or if that arg was not provided.
-	if ResourceType != "" && ResourceType != input.typeName {
-		config.Debugf("Not running forecasters for %v", input.typeName)
+	if ResourceType != "" && ResourceType != input.TypeName {
+		config.Debugf("Not running forecasters for %v", input.TypeName)
 		return forecast
 	}
 
@@ -202,43 +105,48 @@ func forecastForType(input PredictionInput) Forecast {
 	// Estimate how long the stackActionToEstimate will take
 	// (This is only for spinner output, we calculate total time separately)
 	var stackActionToEstimate StackAction
-	if input.stackExists {
+	if input.StackExists {
 		stackActionToEstimate = Update
 	} else {
 		stackActionToEstimate = Create
 	}
-	est, esterr := GetResourceEstimate(input.typeName, stackActionToEstimate)
+	est, esterr := GetResourceEstimate(input.TypeName, stackActionToEstimate)
 	if esterr != nil {
 		config.Debugf("could not get estimate: %v", esterr)
 		est = 1
 	}
-	config.Debugf("Got resource estimate for %v: %v", input.logicalId, est)
-	spin(input.typeName, input.logicalId, fmt.Sprintf("estimate: %v seconds", est))
+	config.Debugf("Got resource estimate for %v: %v", input.LogicalId, est)
+	spin(input.TypeName, input.LogicalId, fmt.Sprintf("estimate: %v seconds", est))
 	spinner.Pop()
 
-	// Call generic prediction functions that we can run against
-	// all resources, even if there is not a predictor.
+	if !pluginOnly {
+		// Call generic prediction functions that we can run against
+		// all resources, even if there is not a predictor.
 
-	spin(input.typeName, input.logicalId, "exists already?")
+		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(code, false, "Resource with this name already exists")
-	} else {
-		LineNumber = input.resource.Line
-		forecast.Add(code, true, "Resource with this name does not already exist")
-	}
+		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(code, false, "Resource with this name already exists",
+				input.Resource.Line)
+		} else {
+			forecast.Add(code, true, "Resource with this name does not already exist",
+				input.Resource.Line)
+		}
 
-	spinner.Pop()
+		spinner.Pop()
+	}
 
-	// Check permissions
-	if IncludeIAM {
-		err := checkPermissions(input, &forecast)
-		if err != nil {
-			config.Debugf("Unable to check permissions: %v", err)
-			return Forecast{}
+	if !pluginOnly {
+		// Check permissions
+		if IncludeIAM {
+			err := checkPermissions(input, &forecast)
+			if err != nil {
+				config.Debugf("Unable to check permissions: %v", err)
+				return fc.Forecast{}
+			}
 		}
 	}
 
@@ -252,13 +160,21 @@ func forecastForType(input PredictionInput) Forecast {
 
 	// TODO - Regional capabilities. Does this service/feature exist in the region?
 
-	// See if we have a specific forecaster for this type
-	fn, found := forecasters[input.typeName]
+	if !pluginOnly {
+		// See if we have a specific forecaster for this type
+		fn, found := forecasters[input.TypeName]
+
+		if found {
+			// Call the prediction function and append the results
+			config.Debugf("Running forecaster for %v", input.TypeName)
+			forecast.Append(fn(input))
+		}
+	}
 
+	// If we loaded extra forecasters from a plugin, run them
+	fn, found := pluginForecasters[input.TypeName]
 	if found {
-		// Call the prediction function and append the results
-		config.Debugf("Running forecaster for %v", input.typeName)
-		LineNumber = input.resource.Line
+		config.Debugf("Running plugin forecaster for %v", input.TypeName)
 		forecast.Append(fn(input))
 	}
 
@@ -269,7 +185,7 @@ func forecastForType(input PredictionInput) Forecast {
 
 // Query the account to make predictions about deployment failures.
 // Returns true if no failures are predicted.
-func Predict(source cft.Template, stackName string, stack types.Stack, stackExists bool, dc *dc.DeployConfig) bool {
+func Predict(source cft.Template, stackName string, stack types.Stack, stackExists bool, dc *deployconfig.DeployConfig) bool {
 
 	config.Debugf("About to make API calls for failure prediction...")
 
@@ -278,7 +194,9 @@ 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
 
-	forecast := makeForecast("", "")
+	emptyInput := &fc.PredictionInput{}
+	emptyInput.Ignore = fc.Ignore
+	forecast := fc.MakeForecast(emptyInput)
 
 	rootMap := source.Node.Content[0]
 
@@ -298,7 +216,6 @@ func Predict(source cft.Template, stackName string, stack types.Stack, stackExis
 			continue
 		}
 		logicalId := r.Value
-		LineNumber = r.Line
 		config.Debugf("logicalId: %v", logicalId)
 
 		resource := resources.Content[i+1]
@@ -315,15 +232,16 @@ func Predict(source cft.Template, stackName string, stack types.Stack, stackExis
 
 		spinner.Push(fmt.Sprintf("Checking %s: %s", typeName, logicalId))
 
-		input := PredictionInput{}
-		input.logicalId = logicalId
-		input.source = source
-		input.resource = resource
-		input.stackName = stackName
-		input.stackExists = stackExists
-		input.stack = stack
-		input.typeName = typeName
-		input.dc = dc
+		input := fc.PredictionInput{}
+		input.LogicalId = logicalId
+		input.Source = source
+		input.Resource = resource
+		input.StackName = stackName
+		input.StackExists = stackExists
+		input.Stack = stack
+		input.TypeName = typeName
+		input.Dc = dc
+		input.Ignore = fc.Ignore
 		cfg := aws.Config()
 		callerArn, err := iam.GetCallerArn(cfg) // arn:aws:iam::755952356119:role/Admin
 		if err != nil {
@@ -333,10 +251,10 @@ func Predict(source cft.Template, stackName string, stack types.Stack, stackExis
 		if len(arnTokens) != 6 {
 			panic(fmt.Sprintf("unexpected number of tokens in caller arn: %v", callerArn))
 		}
-		input.env = Env{partition: arnTokens[1], region: cfg.Region, account: arnTokens[4]}
-		input.roleArn = RoleArn
-		if input.roleArn == "" {
-			input.roleArn = callerArn
+		input.Env = fc.Env{Partition: arnTokens[1], Region: cfg.Region, Account: arnTokens[4]}
+		input.RoleArn = RoleArn
+		if input.RoleArn == "" {
+			input.RoleArn = callerArn
 		}
 
 		forecast.Append(forecastForType(input))
@@ -476,6 +394,25 @@ resource-specific checks. See the README for more details.
 			panic(err)
 		}
 
+		// Load the plugin if a path was provided
+		if pluginPath != "" {
+			config.Debugf("pluginPath: %s", pluginPath)
+			plg, err := plugin.Open(pluginPath)
+			if err != nil {
+				panic(err)
+			}
+			p, err := plg.Lookup("Plugin")
+			if err != nil {
+				panic(err)
+			}
+			forecastPlugin, ok := p.(fc.ForecastPlugin)
+			if !ok {
+				panic("Could not cast to ForecastPlugin")
+			}
+
+			pluginForecasters = forecastPlugin.GetForecasters()
+		}
+
 		if !Predict(source, stackName, stack, stackExists, dc) {
 			os.Exit(1)
 		}
@@ -494,7 +431,9 @@ func init() {
 	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")
+	Cmd.Flags().StringSliceVar(&fc.Ignore, "ignore", []string{}, "Resource types and specific codes to ignore, separated by commas, for example, AWS::S3::Bucket,F0002")
+	Cmd.Flags().StringVar(&pluginPath, "plugin", "", "Path to a forecast plugin .so")
+	Cmd.Flags().BoolVar(&pluginOnly, "plugin-only", false, "If set, none of the built in prediction functions will be run")
 
 	// 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
diff --git a/internal/cmd/forecast/lambda.go b/internal/cmd/forecast/lambda.go
index b517773f..21bb9849 100644
--- a/internal/cmd/forecast/lambda.go
+++ b/internal/cmd/forecast/lambda.go
@@ -7,49 +7,49 @@ import (
 	"github.com/aws-cloudformation/rain/internal/aws/s3"
 	"github.com/aws-cloudformation/rain/internal/config"
 	"github.com/aws-cloudformation/rain/internal/console/spinner"
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
 	"gopkg.in/yaml.v3"
 )
 
-func checkLambdaRole(input *PredictionInput, forecast *Forecast) {
+func checkLambdaRole(input *fc.PredictionInput, forecast *fc.Forecast) {
 
 	roleProp := input.GetPropertyNode("Role")
 
 	// If the role is specified, and it's a scalar, check if it exists
 	if roleProp != nil && roleProp.Kind == yaml.ScalarNode {
-		spin(input.typeName, input.logicalId, "Checking if lambda role exists")
+		spin(input.TypeName, input.LogicalId, "Checking if lambda role exists")
 		roleArn := roleProp.Value
-		LineNumber = roleProp.Line
 		if !iam.RoleExists(roleArn) {
-			forecast.Add(F0016, false, "Role does not exist")
+			forecast.Add(F0016, false, "Role does not exist", roleProp.Line)
 		} else {
-			forecast.Add(F0016, true, "Role exists")
+			forecast.Add(F0016, true, "Role exists", roleProp.Line)
 		}
 		spinner.Pop()
 
 		// Check to make sure the iam role can be assumed by the lambda function
-		spin(input.typeName, input.logicalId, "Checking if lambda role can be assumed")
+		spin(input.TypeName, input.LogicalId, "Checking if lambda role can be assumed")
 		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")
+				forecast.Add(F0017, false, "Role can not be assumed", input.Resource.Line)
 			} else {
-				forecast.Add(F0017, true, "Role can be assumed")
+				forecast.Add(F0017, true, "Role can be assumed", input.Resource.Line)
 			}
 		}
 		spinner.Pop()
 	}
 }
 
-func checkLambdaS3Bucket(input *PredictionInput, forecast *Forecast) {
+func checkLambdaS3Bucket(input *fc.PredictionInput, forecast *fc.Forecast) {
 	// If the lambda function has an s3 bucket and key, make sure they exist
 	codeProp := input.GetPropertyNode("Code")
 	if codeProp != nil {
 		s3Bucket := GetNode(codeProp, "S3Bucket")
 		s3Key := GetNode(codeProp, "S3Key")
 		if s3Bucket != nil && s3Key != nil {
-			spin(input.typeName, input.logicalId,
+			spin(input.TypeName, input.LogicalId,
 				fmt.Sprintf("Checking to see if S3 object %s/%s exists",
 					s3Bucket.Value, s3Key.Value))
 
@@ -59,17 +59,17 @@ func checkLambdaS3Bucket(input *PredictionInput, forecast *Forecast) {
 				config.Debugf("Unable to check if S3 bucket exists: %v", err)
 			}
 			if !exists {
-				forecast.Add(F0019, false, "S3 bucket does not exist")
+				forecast.Add(F0019, false, "S3 bucket does not exist", input.Resource.Line)
 			} else {
-				forecast.Add(F0019, true, "S3 bucket exists")
+				forecast.Add(F0019, true, "S3 bucket exists", input.Resource.Line)
 
 				// If the bucket exists, check to see if the object exists
 				obj, err := s3.HeadObject(s3Bucket.Value, s3Key.Value)
 
 				if err != nil || obj == nil {
-					forecast.Add(F0020, false, "S3 object does not exist")
+					forecast.Add(F0020, false, "S3 object does not exist", input.Resource.Line)
 				} else {
-					forecast.Add(F0020, true, "S3 object exists")
+					forecast.Add(F0020, true, "S3 object exists", input.Resource.Line)
 
 					config.Debugf("S3 Object %s/%s SizeBytes: %v",
 						s3Bucket.Value, s3Key.Value, obj.SizeBytes)
@@ -89,18 +89,18 @@ func checkLambdaS3Bucket(input *PredictionInput, forecast *Forecast) {
 							if err != nil {
 								config.Debugf("Unable to unzip object: %v", err)
 							} else if unzippedSize == 0 {
-								forecast.Add(F0021, false, "S3 object has a zero byte unzipped size")
+								forecast.Add(F0021, false, "S3 object has a zero byte unzipped size", input.Resource.Line)
 							} else {
-								forecast.Add(F0021, true, "S3 object has a non-zero unzipped size")
+								forecast.Add(F0021, true, "S3 object has a non-zero unzipped size", input.Resource.Line)
 							}
 						} else {
-							forecast.Add(F0021, true, "S3 object has a non-zero length less than 50Mb")
+							forecast.Add(F0021, true, "S3 object has a non-zero length less than 50Mb", input.Resource.Line)
 						}
 					} else {
 						if obj.SizeBytes == 0 {
-							forecast.Add(F0021, false, "S3 object has zero bytes")
+							forecast.Add(F0021, false, "S3 object has zero bytes", input.Resource.Line)
 						} else {
-							forecast.Add(F0021, false, "S3 object is greater than 50Mb")
+							forecast.Add(F0021, false, "S3 object is greater than 50Mb", input.Resource.Line)
 						}
 					}
 				}
@@ -108,17 +108,17 @@ func checkLambdaS3Bucket(input *PredictionInput, forecast *Forecast) {
 
 			spinner.Pop()
 		} else {
-			config.Debugf("%s does not have S3Bucket and S3Key", input.logicalId)
+			config.Debugf("%s does not have S3Bucket and S3Key", input.LogicalId)
 		}
 	} else {
-		config.Debugf("Unexpected missing Code property from %s", input.logicalId)
+		config.Debugf("Unexpected missing Code property from %s", input.LogicalId)
 	}
 }
 
 // checkLambdaFunction checks for potential stack failures related to functions
-func CheckLambdaFunction(input PredictionInput) Forecast {
+func CheckLambdaFunction(input fc.PredictionInput) fc.Forecast {
 
-	forecast := makeForecast(input.typeName, input.logicalId)
+	forecast := fc.MakeForecast(&input)
 
 	checkLambdaRole(&input, &forecast)
 
diff --git a/internal/cmd/forecast/launch_config.go b/internal/cmd/forecast/launch_config.go
index dc7903c7..208d2525 100644
--- a/internal/cmd/forecast/launch_config.go
+++ b/internal/cmd/forecast/launch_config.go
@@ -1,12 +1,15 @@
 package forecast
 
-import "github.com/aws-cloudformation/rain/internal/config"
+import (
+	"github.com/aws-cloudformation/rain/internal/config"
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
+)
 
 // AWS::AutoScaling::LaunchConfiguration
 
-func CheckAutoScalingLaunchConfiguration(input PredictionInput) Forecast {
+func CheckAutoScalingLaunchConfiguration(input fc.PredictionInput) fc.Forecast {
 
-	forecast := makeForecast(input.typeName, input.logicalId)
+	forecast := fc.MakeForecast(&input)
 
 	config.Debugf("About to check key name for launch config")
 
diff --git a/internal/cmd/forecast/permissions.go b/internal/cmd/forecast/permissions.go
index c50307b7..e1f2d83b 100644
--- a/internal/cmd/forecast/permissions.go
+++ b/internal/cmd/forecast/permissions.go
@@ -8,17 +8,18 @@ import (
 	"github.com/aws-cloudformation/rain/internal/aws/iam"
 	"github.com/aws-cloudformation/rain/internal/config"
 	"github.com/aws-cloudformation/rain/internal/console/spinner"
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
 	"golang.org/x/exp/slices"
 )
 
 // Returns true if the user has the required permissions on the resource
 // verb is create, delete, or update
-func checkTypePermissions(input PredictionInput, resourceArn string, verb string) (bool, []string) {
+func checkTypePermissions(input fc.PredictionInput, resourceArn string, verb string) (bool, []string) {
 
-	spin(input.typeName, input.logicalId, "permitted?")
+	spin(input.TypeName, input.LogicalId, "permitted?")
 
 	// Go get the list of permissions from the registry
-	actions, err := cfn.GetTypePermissions(input.typeName, verb)
+	actions, err := cfn.GetTypePermissions(input.TypeName, verb)
 	if err != nil {
 		return false, []string{err.Error()}
 	}
@@ -31,7 +32,7 @@ func checkTypePermissions(input PredictionInput, resourceArn string, verb string
 	// TODO - Is there a way we can figure out the arns for related services?
 	// This would likely not be practical in a generic way,
 	// but it's something we should eventually add to custom handling for each service.
-	svcLower := strings.ToLower(strings.Split(input.typeName, "::")[1])
+	svcLower := strings.ToLower(strings.Split(input.TypeName, "::")[1])
 	actionsToRemove := make([]string, 0)
 	for _, action := range actions {
 		// Remove all actions that don't belong to this service
@@ -48,7 +49,7 @@ func checkTypePermissions(input PredictionInput, resourceArn string, verb string
 		actionsToRemove = append(actionsToRemove, "lambda:GetCodeSigningConfig")
 		actionsToRemove = append(actionsToRemove, "lambda:GetLayerVersion")
 	}
-	if input.typeName == "AWS::IAM::Policy" {
+	if input.TypeName == "AWS::IAM::Policy" {
 		actionsToRemove = append(actionsToRemove, "iam:PutUserPolicy")
 		actionsToRemove = append(actionsToRemove, "iam:PutRolePolicy")
 		actionsToRemove = append(actionsToRemove, "iam:PutGroupPolicy")
@@ -70,23 +71,23 @@ func checkTypePermissions(input PredictionInput, resourceArn string, verb string
 
 	// Update the spinner with the action being checked
 	spinnerCallback := func(action string) {
-		spin(input.typeName, input.logicalId, action+" permitted?")
+		spin(input.TypeName, input.LogicalId, action+" permitted?")
 	}
 
 	// Simulate the actions
 	result, messages := iam.Simulate(actionsToCheck,
-		resourceArn, input.roleArn, spinnerCallback)
+		resourceArn, input.RoleArn, spinnerCallback)
 
 	spinner.Pop()
 	return result, messages
 }
 
 // Check permissions to make sure the current role can create-update-delete
-func checkPermissions(input PredictionInput, forecast *Forecast) error {
+func checkPermissions(input fc.PredictionInput, forecast *fc.Forecast) error {
 	resourceArn := predictResourceArn(input)
 	if resourceArn == "" {
 		// We don't know how to make an arn for this type
-		config.Debugf("Can't check permissions for %v %v, ARN unknown", input.typeName, input.logicalId)
+		config.Debugf("Can't check permissions for %v %v, ARN unknown", input.TypeName, input.LogicalId)
 		return nil
 	}
 
@@ -94,29 +95,29 @@ func checkPermissions(input PredictionInput, forecast *Forecast) error {
 
 	var ok bool
 	var reason []string
-	if input.stackExists {
+	if input.StackExists {
 		ok, reason = checkTypePermissions(input, resourceArn, "update")
 		if !ok {
 			forecast.Add(code, false,
-				fmt.Sprintf("Insufficient permissions to update %v\n\t%v", resourceArn, strings.Join(reason, "\n\t")))
+				fmt.Sprintf("Insufficient permissions to update %v\n\t%v", resourceArn, strings.Join(reason, "\n\t")), input.Resource.Line)
 		} else {
-			forecast.Add(code, true, "Role has update permissions")
+			forecast.Add(code, true, "Role has update permissions", input.Resource.Line)
 		}
 
 		ok, reason = checkTypePermissions(input, resourceArn, "delete")
 		if !ok {
 			forecast.Add(code, false,
-				fmt.Sprintf("Insufficient permissions to delete %v\n\t%v", resourceArn, strings.Join(reason, "\n\t")))
+				fmt.Sprintf("Insufficient permissions to delete %v\n\t%v", resourceArn, strings.Join(reason, "\n\t")), input.Resource.Line)
 		} else {
-			forecast.Add(code, true, "Role has delete permissions")
+			forecast.Add(code, true, "Role has delete permissions", input.Resource.Line)
 		}
 	} else {
 		ok, reason = checkTypePermissions(input, resourceArn, "create")
 		if !ok {
 			forecast.Add(code, false,
-				fmt.Sprintf("Insufficient permissions to create %v\n\t%v", resourceArn, strings.Join(reason, "\n\t")))
+				fmt.Sprintf("Insufficient permissions to create %v\n\t%v", resourceArn, strings.Join(reason, "\n\t")), input.Resource.Line)
 		} else {
-			forecast.Add(code, true, "Role has create permissions")
+			forecast.Add(code, true, "Role has create permissions", input.Resource.Line)
 		}
 	}
 	return nil
diff --git a/internal/cmd/forecast/rds_dbcluster.go b/internal/cmd/forecast/rds_dbcluster.go
index 1f8f0bd0..bebdacf1 100644
--- a/internal/cmd/forecast/rds_dbcluster.go
+++ b/internal/cmd/forecast/rds_dbcluster.go
@@ -12,23 +12,24 @@ import (
 	"github.com/aws-cloudformation/rain/internal/console/spinner"
 	"github.com/aws-cloudformation/rain/internal/node"
 	"github.com/aws-cloudformation/rain/internal/s11n"
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
 	"gopkg.in/yaml.v3"
 )
 
 // Checks configuration issues with RDS clusters
-func CheckRDSDBCluster(input PredictionInput) Forecast {
-	forecast := makeForecast(input.typeName, input.logicalId)
+func CheckRDSDBCluster(input fc.PredictionInput) fc.Forecast {
+	forecast := fc.MakeForecast(&input)
 
 	// Resource handler returned message: "Cannot find version 11.16 for aurora-postgresql (Service: Rds, Status Code: 400
 	// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbcluster.html#cfn-rds-dbcluster-engineversion
 
-	_, props, _ := s11n.GetMapValue(input.resource, "Properties")
+	_, props, _ := s11n.GetMapValue(input.Resource, "Properties")
 	if props == nil {
-		config.Debugf("expected %s to have Properties", input.logicalId)
+		config.Debugf("expected %s to have Properties", input.LogicalId)
 		return forecast
 	}
 
-	spin(input.typeName, input.logicalId, "db cluster has correct engine version?")
+	spin(input.TypeName, input.LogicalId, "db cluster has correct engine version?")
 
 	var clusterEngineVersion string
 
@@ -58,16 +59,15 @@ func CheckRDSDBCluster(input PredictionInput) Forecast {
 				}
 			}
 			if unexpected {
-				LineNumber = input.resource.Line
-				config.Debugf("db cluster resource: %s", node.ToJson(input.resource))
-				forecast.Add(code, false, fmt.Sprintf("unexpected EngineVersion: %s", engineVersion.Value))
+				config.Debugf("db cluster resource: %s", node.ToJson(input.Resource))
+				forecast.Add(code, false, fmt.Sprintf("unexpected EngineVersion: %s", engineVersion.Value), input.Resource.Line)
 			} else {
-				forecast.Add(code, true, "EngineVersion ok")
+				forecast.Add(code, true, "EngineVersion ok", input.Resource.Line)
 			}
 		default:
 			config.Debugf("unexpected Engine value for %s: %s",
-				input.logicalId, engine.Value)
-			forecast.Add(code, false, "unexpected Engine value")
+				input.LogicalId, engine.Value)
+			forecast.Add(code, false, "unexpected Engine value", input.Resource.Line)
 		}
 	}
 
@@ -75,52 +75,54 @@ func CheckRDSDBCluster(input PredictionInput) Forecast {
 
 	code = F0004
 
-	spin(input.typeName, input.logicalId, "db cluster has MonitoringRoleARN?")
+	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.
 	_, monitoringRoleARN, _ := s11n.GetMapValue(props, "MonitoringRoleARN")
 	_, monitoringInterval, _ := s11n.GetMapValue(props, "MonitoringInterval")
 	if monitoringInterval != nil && monitoringInterval.Value != "0" {
 		if monitoringRoleARN == nil {
-			forecast.Add(code, 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.", input.Resource.Line)
 		} else {
 			// Make sure the role actually exists
 			if monitoringRoleARN.Kind == yaml.ScalarNode &&
 				!iam.RoleExists(monitoringRoleARN.Value) {
 				forecast.Add(code, false,
 					fmt.Sprintf("MonitoringRoleARN not found: %s",
-						monitoringRoleARN.Value))
+						monitoringRoleARN.Value), input.Resource.Line)
 			} else {
-				forecast.Add(code, true, "MonitoringRoleARN set")
+				forecast.Add(code, true, "MonitoringRoleARN set", input.Resource.Line)
 			}
 		}
 	} else {
-		forecast.Add(code, true, "MonitoringInterval not set to something other than 0")
+		forecast.Add(code, true, "MonitoringInterval not set to something other than 0",
+			input.Resource.Line)
 	}
 
 	spinner.Pop()
 
 	code = F0005
 
-	spin(input.typeName, input.logicalId, "db clusters not at quota")
+	spin(input.TypeName, input.LogicalId, "db clusters not at quota")
 
 	// Check to make sure we're not at quota
-	if !input.stackExists {
+	if !input.StackExists {
 		quota, err := servicequotas.GetQuota("rds", "L-952B80B8")
 		if err != nil {
-			forecast.Add(code, false, fmt.Sprintf("failed: %v", err))
+			forecast.Add(code, false, fmt.Sprintf("failed: %v", err), input.Resource.Line)
 		} else {
 			// Get the number of clusters
 			numClusters, err := rds.GetNumClusters()
 			if err != nil {
-				forecast.Add(code, false, fmt.Sprintf("failed: %v", err))
+				forecast.Add(code, false, fmt.Sprintf("failed: %v", err), input.Resource.Line)
 			} else {
 				if numClusters >= int(math.Round(quota)) {
-					forecast.Add(code, false, "already at quota for number of clusters")
+					forecast.Add(code, false, "already at quota for number of clusters",
+						input.Resource.Line)
 				} else {
 					forecast.Add(code, true,
 						fmt.Sprintf("quota for clusters ok: %v/%v",
-							numClusters, quota))
+							numClusters, quota), input.Resource.Line)
 				}
 			}
 		}
@@ -133,11 +135,11 @@ func CheckRDSDBCluster(input PredictionInput) Forecast {
 	// 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
 
-	spin(input.typeName, input.logicalId, "db cluster engine version matches instances")
+	spin(input.TypeName, input.LogicalId, "db cluster engine version matches instances")
 
 	// TODO: Move this to DBInstance checks when we implement them
 
-	resources, err := input.source.GetSection(cft.Resources)
+	resources, err := input.Source.GetSection(cft.Resources)
 	if err == nil {
 		for i := 0; i < len(resources.Content); i += 2 {
 			logicalId := resources.Content[i].Value
@@ -155,7 +157,7 @@ func CheckRDSDBCluster(input PredictionInput) Forecast {
 							config.Debugf("instanceVersion: %s", node.ToSJson(evNode))
 
 							// Resolve refs first
-							resolveParamRefs(propName, evNode, input.dc, instanceProps)
+							resolveParamRefs(propName, evNode, input.Dc, instanceProps)
 
 							config.Debugf("instanceVersion after: %s", node.ToSJson(evNode))
 
@@ -163,9 +165,11 @@ func CheckRDSDBCluster(input PredictionInput) Forecast {
 							if evNode.Kind == yaml.ScalarNode && instanceVersion != clusterEngineVersion {
 								forecast.Add(code, false, fmt.Sprintf(
 									"engine mismatch with %s: %s != %s",
-									logicalId, instanceVersion, clusterEngineVersion))
+									logicalId, instanceVersion, clusterEngineVersion),
+									input.Resource.Line)
 							} else {
-								forecast.Add(code, true, "instance engine version matches")
+								forecast.Add(code, true, "instance engine version matches",
+									input.Resource.Line)
 							}
 						}
 					}
diff --git a/internal/cmd/forecast/resolve.go b/internal/cmd/forecast/resolve.go
index d28aca08..53efcedf 100644
--- a/internal/cmd/forecast/resolve.go
+++ b/internal/cmd/forecast/resolve.go
@@ -1,16 +1,18 @@
 package forecast
 
 import (
+	"strings"
+
 	"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"
+	"github.com/aws-cloudformation/rain/plugins/deployconfig"
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
 	"gopkg.in/yaml.v3"
-	"strings"
 )
 
 // recurse over properties to resolve Refs
-func resolveParamRefs(name string, prop *yaml.Node, dc *dc.DeployConfig, parent *yaml.Node) {
+func resolveParamRefs(name string, prop *yaml.Node, dc *deployconfig.DeployConfig, parent *yaml.Node) {
 	if name == "Ref" && prop.Kind == yaml.ScalarNode {
 
 		for _, param := range dc.Params {
@@ -55,11 +57,11 @@ func resolveParamRefs(name string, prop *yaml.Node, dc *dc.DeployConfig, parent
 	}
 }
 
-func resolveRefs(input PredictionInput) {
-	_, props, _ := s11n.GetMapValue(input.resource, "Properties")
+func resolveRefs(input fc.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)
+			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 0871c152..74c640fd 100644
--- a/internal/cmd/forecast/s3_bucket.go
+++ b/internal/cmd/forecast/s3_bucket.go
@@ -8,13 +8,14 @@ import (
 	"github.com/aws-cloudformation/rain/internal/config"
 	"github.com/aws-cloudformation/rain/internal/console/spinner"
 	"github.com/aws-cloudformation/rain/internal/s11n"
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
 	"github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
 )
 
 // An empty bucket cannot be deleted, which will cause a stack DELETE to fail.
 // Returns true if the stack operation will succeed.
-func checkBucketNotEmpty(input PredictionInput, bucket *types.StackResourceDetail) (bool, string) {
-	if !input.stackExists {
+func checkBucketNotEmpty(input fc.PredictionInput, bucket *types.StackResourceDetail) (bool, string) {
+	if !input.StackExists {
 		return true, "Stack does not exist"
 	}
 
@@ -22,7 +23,7 @@ func checkBucketNotEmpty(input PredictionInput, bucket *types.StackResourceDetai
 		return false, "bucket is nil"
 	}
 
-	spin(input.typeName, input.logicalId, "bucket not empty?")
+	spin(input.TypeName, input.LogicalId, "bucket not empty?")
 
 	config.Debugf("Checking if the bucket %v is not empty", *bucket.PhysicalResourceId)
 
@@ -40,7 +41,7 @@ func checkBucketNotEmpty(input PredictionInput, bucket *types.StackResourceDetai
 	hasContents, _ := s3.BucketHasContents(*bucket.PhysicalResourceId)
 	if hasContents {
 		// Check the deletion policy
-		_, deletionPolicy, _ := s11n.GetMapValue(input.resource, "DeletionPolicy")
+		_, deletionPolicy, _ := s11n.GetMapValue(input.Resource, "DeletionPolicy")
 		if deletionPolicy != nil && deletionPolicy.Value == "Retain" {
 			// The bucket is not empty but it is set to retain,
 			// so a stack DELETE will not fail
@@ -59,16 +60,16 @@ func checkBucketNotEmpty(input PredictionInput, bucket *types.StackResourceDetai
 }
 
 // Check everything that could go wrong with an AWS::S3::Bucket resource
-func CheckS3Bucket(input PredictionInput) Forecast {
+func CheckS3Bucket(input fc.PredictionInput) fc.Forecast {
 
-	forecast := makeForecast(input.typeName, input.logicalId)
+	forecast := fc.MakeForecast(&input)
 
-	if input.stackExists {
-		res, err := cfn.GetStackResource(input.stackName, input.logicalId)
+	if input.StackExists {
+		res, err := cfn.GetStackResource(input.StackName, input.LogicalId)
 
 		if err != nil {
 			// If this is an update, the bucket might not exist yet
-			config.Debugf("Unable to get details for %v: %v", input.logicalId, err)
+			config.Debugf("Unable to get details for %v: %v", input.LogicalId, err)
 		} else {
 			// The bucket exists
 			bucketName := *res.PhysicalResourceId
@@ -77,9 +78,9 @@ func CheckS3Bucket(input PredictionInput) Forecast {
 			empty, reason := checkBucketNotEmpty(input, res)
 			code := F0001
 			if !empty {
-				forecast.Add(code, false, reason)
+				forecast.Add(code, false, reason, input.Resource.Line)
 			} else {
-				forecast.Add(code, true, "Bucket is empty")
+				forecast.Add(code, true, "Bucket is empty", input.Resource.Line)
 			}
 		}
 	} else {
diff --git a/internal/cmd/forecast/s3_bucketpolicy.go b/internal/cmd/forecast/s3_bucketpolicy.go
index d3c3432f..6770dd18 100644
--- a/internal/cmd/forecast/s3_bucketpolicy.go
+++ b/internal/cmd/forecast/s3_bucketpolicy.go
@@ -8,31 +8,32 @@ import (
 	"github.com/aws-cloudformation/rain/internal/config"
 	"github.com/aws-cloudformation/rain/internal/console/spinner"
 	"github.com/aws-cloudformation/rain/internal/s11n"
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
 )
 
 // Check everything that could go wrong with an AWS::S3::Bucket resource.
 // Returns numFailed, numChecked
-func CheckS3BucketPolicy(input PredictionInput) Forecast {
+func CheckS3BucketPolicy(input fc.PredictionInput) fc.Forecast {
 
-	forecast := makeForecast(input.typeName, input.logicalId)
+	forecast := fc.MakeForecast(&input)
 
-	spin(input.typeName, input.logicalId, "bucket policy")
+	spin(input.TypeName, input.LogicalId, "bucket policy")
 
-	if input.stackExists {
-		_, err := cfn.GetStackResource(input.stackName, input.logicalId)
+	if input.StackExists {
+		_, err := cfn.GetStackResource(input.StackName, input.LogicalId)
 
 		// Do we need this?
 
 		if err != nil {
 			// Likely the resource has been added after the stack was created
-			config.Debugf("Unable to get stack resource %v: %v", input.logicalId, err)
+			config.Debugf("Unable to get stack resource %v: %v", input.LogicalId, err)
 		}
 	}
 
 	// Go back to the template to get the referenced bucket
 
 	// Check the policy for invalid principals
-	_, props, _ := s11n.GetMapValue(input.resource, "Properties")
+	_, props, _ := s11n.GetMapValue(input.Resource, "Properties")
 	if props != nil {
 		_, policyDocument, _ := s11n.GetMapValue(props, "PolicyDocument")
 		if policyDocument != nil {
@@ -41,14 +42,16 @@ func CheckS3BucketPolicy(input PredictionInput) Forecast {
 			code := F0002
 
 			if err != nil {
-				forecast.Add(code, false, fmt.Sprintf("Unable to check policy document: %v", err))
+				forecast.Add(code,
+					false, fmt.Sprintf("Unable to check policy document: %v", err),
+					input.Resource.Line)
 			}
 
 			if !res {
-				LineNumber = policyDocument.Line
-				forecast.Add(code, false, "Invalid principal in policy document")
+				forecast.Add(code, false, "Invalid principal in policy document",
+					policyDocument.Line)
 			} else {
-				forecast.Add(code, true, "Principal is valid")
+				forecast.Add(code, true, "Principal is valid", input.Resource.Line)
 			}
 		}
 	}
diff --git a/internal/cmd/forecast/sagemaker.go b/internal/cmd/forecast/sagemaker.go
index cca9c7dc..597473e0 100644
--- a/internal/cmd/forecast/sagemaker.go
+++ b/internal/cmd/forecast/sagemaker.go
@@ -10,6 +10,7 @@ import (
 	"github.com/aws-cloudformation/rain/internal/config"
 	"github.com/aws-cloudformation/rain/internal/console/spinner"
 	"github.com/aws-cloudformation/rain/internal/s11n"
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
 )
 
 //go:embed sagemaker-notebook-instance-codes.json
@@ -37,24 +38,24 @@ func ParseNotebookCodes(jsonData string) ([]NotebookCode, error) {
 	return retval, nil
 }
 
-func CheckSageMakerNotebook(input PredictionInput) Forecast {
+func CheckSageMakerNotebook(input fc.PredictionInput) fc.Forecast {
 	// AWS::SageMaker::NotebookInstance
 
-	forecast := makeForecast(input.typeName, input.logicalId)
+	forecast := fc.MakeForecast(&input)
 
 	checkNotebookLimit(&input, &forecast)
 
 	return forecast
 }
 
-func checkNotebookLimit(input *PredictionInput, forecast *Forecast) {
+func checkNotebookLimit(input *fc.PredictionInput, forecast *fc.Forecast) {
 
 	// The account- service limit 'Total number of notebook instances' is 8
 	// NotebookInstances, with current utilization of 16 NotebookInstances and
 	// a request delta of 1 NotebookInstances. Please contact AWS support to
 	// request an increase for this limit.
 
-	spin(input.typeName, input.logicalId, "SageMaker notebook quota ok?")
+	spin(input.TypeName, input.LogicalId, "SageMaker notebook quota ok?")
 	defer spinner.Pop()
 
 	atLimit := false
@@ -111,18 +112,18 @@ func checkNotebookLimit(input *PredictionInput, forecast *Forecast) {
 
 		// Get the instance type from the resource we are checking
 		var resourceInstanceType string
-		_, props, _ := s11n.GetMapValue(input.resource, "Properties")
+		_, props, _ := s11n.GetMapValue(input.Resource, "Properties")
 		if props == nil {
-			config.Debugf("expected %s to have Properties", input.logicalId)
+			config.Debugf("expected %s to have Properties", input.LogicalId)
 			return
 		}
 		_, instanceTypeNode, _ := s11n.GetMapValue(props, "InstanceType")
 		if instanceTypeNode == nil {
-			config.Debugf("expected %s to have InstanceType", input.logicalId)
+			config.Debugf("expected %s to have InstanceType", input.LogicalId)
 			return
 		}
 		resourceInstanceType = instanceTypeNode.Value
-		config.Debugf("%s InstanceType is %s", input.logicalId, resourceInstanceType)
+		config.Debugf("%s InstanceType is %s", input.LogicalId, resourceInstanceType)
 
 		code, ok := codeMap[resourceInstanceType]
 		if !ok {
@@ -147,9 +148,11 @@ func checkNotebookLimit(input *PredictionInput, forecast *Forecast) {
 
 	forecastCode := F0018
 	if !atLimit {
-		forecast.Add(forecastCode, true, "Quota limit has not been reached")
+		forecast.Add(forecastCode, true, "Quota limit has not been reached",
+			input.Resource.Line)
 	} else {
-		forecast.Add(forecastCode, false, "Over quota limit")
+		forecast.Add(forecastCode, false, "Over quota limit",
+			input.Resource.Line)
 	}
 
 }
diff --git a/internal/cmd/forecast/sns_topic.go b/internal/cmd/forecast/sns_topic.go
index 37095e8a..ad7d28b9 100644
--- a/internal/cmd/forecast/sns_topic.go
+++ b/internal/cmd/forecast/sns_topic.go
@@ -3,20 +3,21 @@ package forecast
 import (
 	"github.com/aws-cloudformation/rain/internal/aws/kms"
 	"github.com/aws-cloudformation/rain/internal/console/spinner"
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
 )
 
-func CheckSNSTopic(input PredictionInput) Forecast {
+func CheckSNSTopic(input fc.PredictionInput) fc.Forecast {
 
-	forecast := makeForecast(input.typeName, input.logicalId)
+	forecast := fc.MakeForecast(&input)
 
-	spin(input.typeName, input.logicalId, "Checking SNS Topic Key")
+	spin(input.TypeName, input.LogicalId, "Checking SNS Topic Key")
 	checkSNSTopicKey(&input, &forecast)
 	spinner.Pop()
 
 	return forecast
 }
 
-func checkSNSTopicKey(input *PredictionInput, forecast *Forecast) {
+func checkSNSTopicKey(input *fc.PredictionInput, forecast *fc.Forecast) {
 
 	// Get the KmsMasterKeyId from the input resource properties
 	k := input.GetPropertyNode("KmsMasterKeyId")
@@ -24,9 +25,9 @@ func checkSNSTopicKey(input *PredictionInput, forecast *Forecast) {
 		keyArn := k.Value
 		valid := kms.IsKeyArnValid(keyArn)
 		if valid {
-			forecast.Add(F0013, true, "KMS Key is valid")
+			forecast.Add(F0013, true, "KMS Key is valid", input.Resource.Line)
 		} else {
-			forecast.Add(F0013, false, "KMS Key is invalid")
+			forecast.Add(F0013, false, "KMS Key is invalid", input.Resource.Line)
 		}
 	}
 }
diff --git a/internal/dc/dc.go b/internal/dc/dc.go
index 35066033..a4050473 100644
--- a/internal/dc/dc.go
+++ b/internal/dc/dc.go
@@ -15,6 +15,7 @@ import (
 	"github.com/aws-cloudformation/rain/internal/console"
 	"github.com/aws-cloudformation/rain/internal/console/spinner"
 	"github.com/aws-cloudformation/rain/internal/ui"
+	"github.com/aws-cloudformation/rain/plugins/deployconfig"
 	"github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
 	"github.com/aws/smithy-go/ptr"
 	"gopkg.in/yaml.v2"
@@ -27,26 +28,6 @@ type configFileFormat struct {
 	LowerTags       map[string]string `yaml:"tags,omitempty"`
 }
 
-// DeployConfig represents the user-supplied configuration for a deployment
-// This is also used by the forecast command
-type DeployConfig struct {
-	Params []types.Parameter
-	Tags   map[string]string
-}
-
-// GetParam gets the value of a supplied parameter
-func (dc DeployConfig) GetParam(name string) (string, bool) {
-	for _, p := range dc.Params {
-		if *p.ParameterKey == name {
-			if p.ResolvedValue != nil {
-				return *p.ResolvedValue, true
-			}
-			return *p.ParameterValue, true
-		}
-	}
-	return "", false
-}
-
 // GetParameters checks the combined params supplied as args and in a file
 // and asks the user to supply any values that are missing
 func GetParameters(
@@ -226,9 +207,9 @@ func GetDeployConfig(
 	stack types.Stack,
 	stackExists bool,
 	yes bool,
-	ignoreUnknownParams bool) (*DeployConfig, error) {
+	ignoreUnknownParams bool) (*deployconfig.DeployConfig, error) {
 
-	dc := &DeployConfig{}
+	dc := &deployconfig.DeployConfig{}
 
 	// Parse tags
 	parsedTagFlag := ListToMap("tag", tags)
diff --git a/plugins/deployconfig/deployconfig.go b/plugins/deployconfig/deployconfig.go
new file mode 100644
index 00000000..35c3aebf
--- /dev/null
+++ b/plugins/deployconfig/deployconfig.go
@@ -0,0 +1,23 @@
+package deployconfig
+
+import "github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
+
+// DeployConfig represents the user-supplied configuration for a deployment
+// This is also used by the forecast command
+type DeployConfig struct {
+	Params []types.Parameter
+	Tags   map[string]string
+}
+
+// GetParam gets the value of a supplied parameter
+func (dc DeployConfig) GetParam(name string) (string, bool) {
+	for _, p := range dc.Params {
+		if *p.ParameterKey == name {
+			if p.ResolvedValue != nil {
+				return *p.ResolvedValue, true
+			}
+			return *p.ParameterValue, true
+		}
+	}
+	return "", false
+}
diff --git a/plugins/forecast/forecast.go b/plugins/forecast/forecast.go
new file mode 100644
index 00000000..d79b3116
--- /dev/null
+++ b/plugins/forecast/forecast.go
@@ -0,0 +1,130 @@
+package forecast
+
+import (
+	"fmt"
+	"slices"
+
+	"github.com/aws-cloudformation/rain/cft"
+	"github.com/aws-cloudformation/rain/internal/s11n"
+	"github.com/aws-cloudformation/rain/plugins/deployconfig"
+	"github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
+	"gopkg.in/yaml.v3"
+)
+
+type ForecastPlugin interface {
+	// GetForecasters must be implemented by plugins to return a
+	// map of the prediction functions. The key is the resource type name.
+	GetForecasters() map[string]func(input PredictionInput) Forecast
+}
+
+// PredictionInput is the input to forecast prediction functions
+type PredictionInput struct {
+	Source      cft.Template
+	StackName   string
+	Resource    *yaml.Node
+	LogicalId   string
+	StackExists bool
+	Stack       types.Stack
+	TypeName    string
+	Dc          *deployconfig.DeployConfig
+	Env         Env
+	RoleArn     string
+	Ignore      []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
+}
+
+// Forecast represents predictions for resources in the template
+type Forecast struct {
+	TypeName  string
+	LogicalId string
+	Passed    []Check
+	Failed    []Check
+	Ignore    []string
+	// TODO: Errors []error
+	// Instead of config.Debugf, output unexpected errors
+	// Otherwise users won't know if checks are failing to run,
+	// unless they view debug output
+}
+
+func MakeForecast(input *PredictionInput) Forecast {
+	return Forecast{
+		TypeName:  input.TypeName,
+		LogicalId: input.LogicalId,
+		Ignore:    input.Ignore,
+		Passed:    make([]Check, 0),
+		Failed:    make([]Check, 0),
+	}
+}
+
+// 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 {
+	return len(f.Passed) + len(f.Failed)
+}
+
+func (f *Forecast) GetNumFailed() int {
+	return len(f.Failed)
+}
+
+func (f *Forecast) GetNumPassed() int {
+	return len(f.Passed)
+}
+
+func (f *Forecast) Append(forecast Forecast) {
+	f.Failed = append(f.Failed, forecast.Failed...)
+	f.Passed = append(f.Passed, forecast.Passed...)
+}
+
+// Add adds a pass or fail message, formatting it to include the type name and logical id
+func (f *Forecast) Add(code string, passed bool, message string, lineNumber int) {
+	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(f.Ignore, code) || slices.Contains(f.Ignore, f.TypeName) {
+		return
+	}
+
+	if passed {
+		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 {
+		passFail = "FAIL"
+	}
+	return fmt.Sprintf("%s %s on line %s", c.Code, passFail, c.Message)
+}
+
+type Env struct {
+	Partition string
+	Region    string
+	Account   string
+}
+
+// Which checks to ignore (--ignore)
+var Ignore []string
diff --git a/staticcheck.conf b/staticcheck.conf
new file mode 100644
index 00000000..7d9bdd71
--- /dev/null
+++ b/staticcheck.conf
@@ -0,0 +1,2 @@
+checks = ["all", "-SA1006", "-ST1003", "-ST1020", "-ST1000", "-ST1002", "-ST1021", "-ST1022", "-S1009", "-ST1016"]
+


Test patch:
diff --git a/cft/format/format_test.go b/cft/format/format_test.go
index ef15537a..13e7a5d9 100644
--- a/cft/format/format_test.go
+++ b/cft/format/format_test.go
@@ -535,7 +535,7 @@ Resources:
 	})
 
 	if d := cmp.Diff(expect, actual); d != "" {
-		t.Fatalf(d)
+		t.Fatal(d)
 	}
 }
 
diff --git a/internal/cmd/forecast/forecast_test.go b/internal/cmd/forecast/forecast_test.go
index 0a6ab67e..22038f10 100644
--- a/internal/cmd/forecast/forecast_test.go
+++ b/internal/cmd/forecast/forecast_test.go
@@ -1,11 +1,18 @@
 package forecast
 
-import "testing"
+import (
+	"testing"
+
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
+)
 
 func TestForecastMethods(t *testing.T) {
-	forecast := makeForecast("A::B::C", "Id")
-	forecast.Add("CODE1", true, "Succeeded")
-	forecast.Add("CODE2", false, "Failed")
+	input := fc.PredictionInput{}
+	input.TypeName = "A::B::C"
+	input.LogicalId = "Id"
+	forecast := fc.MakeForecast(&input)
+	forecast.Add("CODE1", true, "Succeeded", 0)
+	forecast.Add("CODE2", false, "Failed", 0)
 
 	if forecast.GetNumChecked() != 2 {
 		t.Errorf("Expected 2 checks")
@@ -19,8 +26,11 @@ func TestForecastMethods(t *testing.T) {
 		t.Errorf("Expected 1 pass")
 	}
 
-	f2 := makeForecast("D::E::F", "Id2")
-	f2.Add("CODE2", false, "f2 fail")
+	input2 := fc.PredictionInput{}
+	input2.TypeName = "A::B::C"
+	input2.LogicalId = "Id"
+	f2 := fc.MakeForecast(&input2)
+	f2.Add("CODE2", false, "f2 fail", 0)
 	forecast.Append(f2)
 
 	if forecast.GetNumChecked() != 3 || forecast.GetNumFailed() != 2 {
diff --git a/internal/cmd/forecast/permissions_test.go b/internal/cmd/forecast/permissions_test.go
index 089f4797..2105becf 100644
--- a/internal/cmd/forecast/permissions_test.go
+++ b/internal/cmd/forecast/permissions_test.go
@@ -5,22 +5,23 @@ import (
 	"testing"
 
 	"github.com/aws-cloudformation/rain/cft"
+	fc "github.com/aws-cloudformation/rain/plugins/forecast"
 	"github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
 	"gopkg.in/yaml.v3"
 )
 
 func TestPredictResourceArn(t *testing.T) {
-	input := PredictionInput{
-		source:      cft.Template{},
-		stackName:   "mystack",
-		resource:    &yaml.Node{},
-		logicalId:   "myresource",
-		stackExists: false,
-		stack:       types.Stack{},
-		typeName:    "AWS::S3::Bucket",
-		dc:          nil,
-		env:         Env{partition: "aws", region: "us-east-1", account: "123456789012"},
-		roleArn:     "arn:aws:iam::123456789012:role/Admin",
+	input := fc.PredictionInput{
+		Source:      cft.Template{},
+		StackName:   "mystack",
+		Resource:    &yaml.Node{},
+		LogicalId:   "myresource",
+		StackExists: false,
+		Stack:       types.Stack{},
+		TypeName:    "AWS::S3::Bucket",
+		Dc:          nil,
+		Env:         fc.Env{Partition: "aws", Region: "us-east-1", Account: "123456789012"},
+		RoleArn:     "arn:aws:iam::123456789012:role/Admin",
 	}
 
 	s3Arn := predictResourceArn(input)
@@ -28,7 +29,7 @@ func TestPredictResourceArn(t *testing.T) {
 		t.Errorf("Expected arn:aws:s3:::*, got %s", s3Arn)
 	}
 
-	input.typeName = "AWS::EC2::Instance"
+	input.TypeName = "AWS::EC2::Instance"
 
 	ec2Arn := predictResourceArn(input)
 	if !strings.HasPrefix(ec2Arn, "arn:aws:ec2:us-east-1:123456789012:instance/") {
diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go
index da879e76..bc885d59 100644
--- a/internal/ui/ui_test.go
+++ b/internal/ui/ui_test.go
@@ -83,6 +83,6 @@ lines
 	actual := Indent("  ", input)
 
 	if d := cmp.Diff(actual, expected); d != "" {
-		t.Errorf(d)
+		t.Error(d)
 	}
 }