Repository: aws-cloudformation/rain (go)

Issue:
Add constants as a !Rain directive
Similar to Terraform locals.

```yaml
!Rain::Constants:
    Hello: World
    Nested:
        A: B
        B: C
        
Resources:
    MyBucket:
        Type: AWS::S3::Bucket
        Properties:
            BucketName: !Rain::Constant Hello

            or

            BucketName: $Hello
```

How would that work in a `!Sub`? 

`!Sub abc-def-!Rain::Constant Hello` is awkward.

`!Sub abc-def-$Hello` is better

Would this conflict with Ref resolution?

Pull request:
Add constant values to a CloudFormation template
Closes #156 

```yaml
Parameters:
  Prefix:
    Type: String

Rain:
  Constants:
    Test1: ezbeard-rain-test-constants
    Test2: !Sub ${Prefix}-${Rain::Test1}-SubTest

Resources:
  Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Rain::Constant Test1

  Bucket2:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Rain::Constant Test2

  Bucket3:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub "pre-${Prefix}-${Rain::Test1}-suffix" 

```

Running `rain pkg` on this template produces:

```yaml
Parameters:
  Prefix:
    Type: String

Resources:
  Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: ezbeard-rain-test-constants

  Bucket2:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub ${Prefix}-ezbeard-rain-test-constants-SubTest

  Bucket3:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub "pre-${Prefix}-ezbeard-rain-test-constants-suffix" 

```

Patch:
diff --git a/cft/cft.go b/cft/cft.go
index c26096c4..d3d1e10b 100644
--- a/cft/cft.go
+++ b/cft/cft.go
@@ -17,7 +17,9 @@ import (
 // Template represents a CloudFormation template. The Template type
 // is minimal for now but will likely grow new features as needed by rain.
 type Template struct {
-	*yaml.Node
+	Node *yaml.Node
+
+	Constants map[string]*yaml.Node
 }
 
 // TODO - We really need a convenient Template data structure
@@ -31,7 +33,7 @@ type Template struct {
 func (t Template) Map() map[string]interface{} {
 	var out map[string]interface{}
 
-	err := t.Decode(&out)
+	err := t.Node.Decode(&out)
 	if err != nil {
 		panic(fmt.Errorf("error converting template to map: %s", err))
 	}
@@ -63,6 +65,7 @@ const (
 	Transform                Section = "Transform"
 	Outputs                  Section = "Outputs"
 	State                    Section = "State"
+	Rain                     Section = "Rain"
 )
 
 // GetResource returns the yaml node for a resource by logical id
@@ -129,6 +132,11 @@ func (t Template) GetSection(section Section) (*yaml.Node, error) {
 	return s, nil
 }
 
+// RemoveSection removes a section node from the template
+func (t Template) RemoveSection(section Section) error {
+	return node.RemoveFromMap(t.Node.Content[0], string(Rain))
+}
+
 // GetTypes returns all unique type names for resources in the template
 func (t Template) GetTypes() ([]string, error) {
 	resources, err := t.GetSection(Resources)
diff --git a/cft/parse/parse_sub.go b/cft/parse/parse_sub.go
index 3fef053c..b5ecbce1 100644
--- a/cft/parse/parse_sub.go
+++ b/cft/parse/parse_sub.go
@@ -19,6 +19,7 @@ const (
 	STR    wordtype = iota // A literal string fragment
 	REF                    // ${ParamOrResourceName}
 	AWS                    // ${AWS::X}
+	RAIN                   // ${Rain::X}
 	GETATT                 // ${X.Y}
 )
 
@@ -90,12 +91,15 @@ func ParseSub(sub string) ([]SubWord, error) {
 				// Figure out what type it is
 				if strings.HasPrefix(buf, "AWS::") {
 					wt = AWS
+				} else if strings.HasPrefix(buf, "Rain::") {
+					wt = RAIN
 				} else if strings.Contains(buf, ".") {
 					wt = GETATT
 				} else {
 					wt = REF
 				}
 				buf = strings.Replace(buf, "AWS::", "", 1)
+				buf = strings.Replace(buf, "Rain::", "", 1)
 				words = append(words, SubWord{T: wt, W: buf})
 				buf = ""
 				state = READSTR
diff --git a/cft/pkg/constants.go b/cft/pkg/constants.go
new file mode 100644
index 00000000..a9b34397
--- /dev/null
+++ b/cft/pkg/constants.go
@@ -0,0 +1,27 @@
+package pkg
+
+import (
+	"fmt"
+
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/aws-cloudformation/rain/internal/node"
+)
+
+// rainConstant parses a !Rain::Constant node
+// Constants can be any type of YAML node, but if the constant is a string, it
+// can be used in a Sub with ${Rain::ConstantName}. Otherwise use a directive.
+// !Rain::Constant ConstantName. Constants are evaluated in order, so they can
+// refer to other constants declared previously.
+func rainConstant(ctx *directiveContext) (bool, error) {
+
+	config.Debugf("Found a rain constant: %s", node.ToSJson(ctx.n))
+	name := ctx.n.Content[1].Value
+	val, ok := ctx.t.Constants[name]
+	if !ok {
+		return false, fmt.Errorf("rain constant %s not found", name)
+	}
+
+	*ctx.n = *val
+
+	return true, nil
+}
diff --git a/cft/pkg/directives.go b/cft/pkg/directives.go
index 32dac897..4ea9f919 100644
--- a/cft/pkg/directives.go
+++ b/cft/pkg/directives.go
@@ -59,6 +59,9 @@ func init() {
 	registry["**/*|Rain::S3Http"] = includeS3Http
 	registry["**/*|Rain::S3"] = includeS3
 	registry["**/*|Rain::Module"] = module
+	registry["**/*|Rain::Constant"] = rainConstant
+
+	// Don't forget to also add new items to cft/tags.go
 }
 
 func includeString(ctx *directiveContext) (bool, error) {
diff --git a/cft/pkg/pkg.go b/cft/pkg/pkg.go
index 2a627a2d..51b9010a 100644
--- a/cft/pkg/pkg.go
+++ b/cft/pkg/pkg.go
@@ -59,6 +59,8 @@ type transformContext struct {
 func transform(ctx *transformContext) (bool, error) {
 	changed := false
 
+	config.Debugf("transform: %s", node.ToSJson(ctx.nodeToTransform))
+
 	// registry is a map of functions defined in rain.go
 	for path, fn := range registry {
 		for found := range s11n.MatchAll(ctx.nodeToTransform, path) {
@@ -77,15 +79,118 @@ func transform(ctx *transformContext) (bool, error) {
 	return changed, nil
 }
 
+// replaceConstants replaces ${Rain::ConstantName} in a single scalar node
+// If the constant name is not found in the map created from the Rain section
+// In the template, an error is returned
+func replaceConstants(n *yaml.Node, constants map[string]*yaml.Node) error {
+	if n.Kind != yaml.ScalarNode {
+		return fmt.Errorf("expected n to be a ScalarNode")
+	}
+
+	// Parse every scalar as if it was a Sub. Look for ${Rain::X}
+
+	retval := ""
+	words, err := parse.ParseSub(n.Value)
+	if err != nil {
+		return err
+	}
+	for _, w := range words {
+		switch w.T {
+		case parse.STR:
+			retval += w.W
+		case parse.REF:
+			retval += fmt.Sprintf("${%s}", w.W)
+		case parse.AWS:
+			retval += fmt.Sprintf("${AWS::%s}", w.W)
+		case parse.GETATT:
+			retval += fmt.Sprintf("${%s}", w.W)
+		case parse.RAIN:
+			val, ok := constants[w.W]
+			if !ok {
+				return fmt.Errorf("did not find Rain constant %s", w.W)
+			}
+			retval += val.Value
+		}
+	}
+
+	config.Debugf("Replacing %s with %s", n.Value, retval)
+	n.Value = retval
+
+	return nil
+}
+
+// replaceTemplateConstants scans the entire template looking for Sub strings
+// and replaces all instances of ${Rain::ConstantName} if that name exists
+// in the Rain/Constants section of the template
+func replaceTemplateConstants(templateNode *yaml.Node, constants map[string]*yaml.Node) {
+
+	config.Debugf("Constants: %v", constants)
+
+	vf := func(n *visitor.Visitor) {
+		yamlNode := n.GetYamlNode()
+		if yamlNode.Kind == yaml.MappingNode {
+			if len(yamlNode.Content) == 2 && yamlNode.Content[0].Value == "Fn::Sub" {
+				config.Debugf("About to replace constants in %s", yamlNode.Content[1].Value)
+				err := replaceConstants(yamlNode.Content[1], constants)
+				if err != nil {
+					config.Debugf("%v", err)
+				}
+			}
+		}
+	}
+
+	visitor := visitor.NewVisitor(templateNode)
+	visitor.Visit(vf)
+}
+
 // Template returns t with assets included as per AWS CLI packaging rules
 // and any Rain:: functions used.
 // rootDir must be passed in so that any included assets can be loaded from the same directory
 func Template(t cft.Template, rootDir string, fs *embed.FS) (cft.Template, error) {
 	templateNode := t.Node
+	var err error
 
 	//config.Debugf("Original template short: %v", node.ToSJson(t.Node))
 	//config.Debugf("Original template long: %v", node.ToJson(t.Node))
 
+	// First look for a Rain section and store constants
+	t.Constants = make(map[string]*yaml.Node)
+	rainNode, err := t.GetSection(cft.Rain)
+	if err != nil {
+		config.Debugf("Unable to get Rain section: %v", err)
+	} else {
+		config.Debugf("Rain node: %s", node.ToSJson(rainNode))
+		_, c, _ := s11n.GetMapValue(rainNode, "Constants")
+		if c != nil {
+			for i := 0; i < len(c.Content); i += 2 {
+				name := c.Content[i].Value
+				val := c.Content[i+1]
+				t.Constants[name] = val
+				// Visit each node in val looking for any ${Rain::ConstantName}
+				// and replace it with prior constant entries
+				vf := func(v *visitor.Visitor) {
+					vnode := v.GetYamlNode()
+					if vnode.Kind == yaml.ScalarNode {
+						err := replaceConstants(vnode, t.Constants)
+						if err != nil {
+							// These constants must be scalars
+							config.Debugf("replaceConstants failed: %v", err)
+						}
+					}
+				}
+				v := visitor.NewVisitor(val)
+				v.Visit(vf)
+			}
+		}
+
+		// Add handling for any other features we add to the Rain section here
+
+		// Now remove the Rain node from the template
+		t.RemoveSection(cft.Rain)
+	}
+
+	constants := t.Constants
+
 	ctx := &transformContext{
 		nodeToTransform: templateNode,
 		rootDir:         rootDir,
@@ -118,7 +223,6 @@ func Template(t cft.Template, rootDir string, fs *embed.FS) (cft.Template, error
 		}
 	}
 
-	var err error
 	if changed {
 		t, err = parse.Node(templateNode)
 		if err != nil {
@@ -155,6 +259,9 @@ func Template(t cft.Template, rootDir string, fs *embed.FS) (cft.Template, error
 	v.Visit(collectAnchors)
 	v.Visit(replaceAnchors)
 
+	// Look for ${Rain::ConstantName} in all Sub strings
+	replaceTemplateConstants(templateNode, constants)
+
 	// Marshal and Unmarshal to resolve new line/column numbers
 
 	serialized, err := yaml.Marshal(templateNode)
diff --git a/cft/tags.go b/cft/tags.go
index d43c9f10..efac0b06 100644
--- a/cft/tags.go
+++ b/cft/tags.go
@@ -2,27 +2,28 @@ package cft
 
 // Tags is a mapping from YAML short tags to full instrincic function names
 var Tags = map[string]string{
-	"!And":           "Fn::And",
-	"!Base64":        "Fn::Base64",
-	"!Cidr":          "Fn::Cidr",
-	"!Equals":        "Fn::Equals",
-	"!FindInMap":     "Fn::FindInMap",
-	"!GetAZs":        "Fn::GetAZs",
-	"!GetAtt":        "Fn::GetAtt",
-	"!If":            "Fn::If",
-	"!ImportValue":   "Fn::ImportValue",
-	"!Join":          "Fn::Join",
-	"!Not":           "Fn::Not",
-	"!Or":            "Fn::Or",
-	"!Select":        "Fn::Select",
-	"!Split":         "Fn::Split",
-	"!Sub":           "Fn::Sub",
-	"!Ref":           "Ref",
-	"!Condition":     "Condition",
-	"!Rain::Embed":   "Rain::Embed",
-	"!Rain::Include": "Rain::Include",
-	"!Rain::Env":     "Rain::Env",
-	"!Rain::S3Http":  "Rain::S3Http",
-	"!Rain::S3":      "Rain::S3",
-	"!Rain::Module":  "Rain::Module",
+	"!And":            "Fn::And",
+	"!Base64":         "Fn::Base64",
+	"!Cidr":           "Fn::Cidr",
+	"!Equals":         "Fn::Equals",
+	"!FindInMap":      "Fn::FindInMap",
+	"!GetAZs":         "Fn::GetAZs",
+	"!GetAtt":         "Fn::GetAtt",
+	"!If":             "Fn::If",
+	"!ImportValue":    "Fn::ImportValue",
+	"!Join":           "Fn::Join",
+	"!Not":            "Fn::Not",
+	"!Or":             "Fn::Or",
+	"!Select":         "Fn::Select",
+	"!Split":          "Fn::Split",
+	"!Sub":            "Fn::Sub",
+	"!Ref":            "Ref",
+	"!Condition":      "Condition",
+	"!Rain::Embed":    "Rain::Embed",
+	"!Rain::Include":  "Rain::Include",
+	"!Rain::Env":      "Rain::Env",
+	"!Rain::S3Http":   "Rain::S3Http",
+	"!Rain::S3":       "Rain::S3",
+	"!Rain::Module":   "Rain::Module",
+	"!Rain::Constant": "Rain::Constant",
 }


Test patch:
diff --git a/cft/parse/parse_sub_test.go b/cft/parse/parse_sub_test.go
index c8779cd2..fabc1da8 100644
--- a/cft/parse/parse_sub_test.go
+++ b/cft/parse/parse_sub_test.go
@@ -69,4 +69,15 @@ func TestParseSub(t *testing.T) {
 	if err == nil {
 		t.Fatalf("\"%s\": should have failed, got %v", sub, words)
 	}
+
+	// Rain constants
+	sub = "${Rain::Test}"
+	words, err = parse.ParseSub(sub)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if words[0].T != parse.RAIN || words[0].W != "Test" {
+		t.Fatalf("Expected to get Test from ${Rain::Test}: %v", words)
+	}
+
 }
diff --git a/cft/pkg/constants_test.go b/cft/pkg/constants_test.go
new file mode 100644
index 00000000..bcd7b234
--- /dev/null
+++ b/cft/pkg/constants_test.go
@@ -0,0 +1,92 @@
+package pkg
+
+import (
+	"testing"
+
+	"github.com/aws-cloudformation/rain/cft/diff"
+	"github.com/aws-cloudformation/rain/cft/parse"
+	"gopkg.in/yaml.v3"
+)
+
+func TestConstants(t *testing.T) {
+	source := `
+Parameters:
+  Prefix:
+    Type: String
+
+Rain:
+  Constants:
+    Test1: ezbeard-rain-test-constants
+    Test2: !Sub ${Prefix}-${Rain::Test1}-SubTest
+
+Resources:
+  Bucket:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketName: !Rain::Constant Test1
+  Bucket2:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketName: !Rain::Constant Test2
+  Bucket3:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketName: !Sub "pre-${Prefix}-${Rain::Test1}-suffix" 
+`
+	expect := `
+Parameters:
+  Prefix:
+    Type: String
+
+Resources:
+  Bucket:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketName: ezbeard-rain-test-constants
+  Bucket2:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketName: !Sub ${Prefix}-ezbeard-rain-test-constants-SubTest
+  Bucket3:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketName: 
+        Fn::Sub: pre-${Prefix}-ezbeard-rain-test-constants-suffix
+`
+
+	//config.Debug = true
+
+	p, err := parse.String(source)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	tmpl, err := Template(p, ".", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	et, err := parse.String(expect)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	d := diff.New(tmpl, et)
+	if d.Mode() != "=" {
+		t.Errorf("Output does not match expected: %v", d.Format(true))
+	}
+
+}
+
+func TestReplaceConstants(t *testing.T) {
+	n := &yaml.Node{Kind: yaml.ScalarNode, Value: "${Rain::Test}"}
+	constants := make(map[string]*yaml.Node)
+	constants["Test"] = &yaml.Node{Kind: yaml.ScalarNode, Value: "Foo"}
+	err := replaceConstants(n, constants)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if n.Value != "Foo" {
+		t.Fatalf("Expected Foo, got %s", n.Value)
+	}
+}
diff --git a/test/templates/constants.yaml b/test/templates/constants.yaml
new file mode 100644
index 00000000..20174188
--- /dev/null
+++ b/test/templates/constants.yaml
@@ -0,0 +1,25 @@
+Parameters:
+  Prefix:
+    Type: String
+
+Rain:
+  Constants:
+    Test1: ezbeard-rain-test-constants
+    Test2: !Sub ${Prefix}-${Rain::Test1}-SubTest
+
+Resources:
+  Bucket:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketName: !Rain::Constant Test1
+
+  Bucket2:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketName: !Rain::Constant Test2
+
+  Bucket3:
+    Type: AWS::S3::Bucket
+    Properties:
+      BucketName: !Sub "pre-${Prefix}-${Rain::Test1}-suffix" 
+