Repository: aws-cloudformation/rain
Pull request #239 (2024-01-17 19:25:48)

Add intrinsic support to cc deploy
Rain CloudControl API deployment now supports `Ref`, `Fn::GetAtt`, and `Fn::Sub`. I also moved some files around, changed the name of the command from `ccdeploy` to `cc deploy`, and added formatted tables to the output.



Patch:
diff --git a/cft/cft.go b/cft/cft.go
index 88a743e2..ad5e731e 100644
--- a/cft/cft.go
+++ b/cft/cft.go
@@ -6,6 +6,7 @@ package cft
 import (
 	"fmt"
 
+	"github.com/aws-cloudformation/rain/internal/s11n"
 	"gopkg.in/yaml.v3"
 )
 
@@ -42,3 +43,43 @@ func AppendStateMap(state Template) *yaml.Node {
 	state.Node.Content[0].Content = append(state.Node.Content[0].Content, stateMap)
 	return stateMap
 }
+
+// Section represents a top level section of a template, like Resources
+type Section string
+
+const (
+	AWSTemplateFormatVersion Section = "AWSTemplateFormatVersion"
+	Resources                Section = "Resources"
+	Description              Section = "Description "
+	Metadata                 Section = "Metadata"
+	Parameters               Section = "Parameters"
+	Rules                    Section = "Rules"
+	Mappings                 Section = "Mappings"
+	Conditions               Section = "Conditions"
+	Transform                Section = "Transform"
+	Outputs                  Section = "Outputs"
+)
+
+// GetResource returns the yaml node for a resource by logical id
+func (t Template) GetResource(name string) (*yaml.Node, error) {
+	return t.GetNode(Resources, name)
+}
+
+// GetParameter returns the yaml node for a parameter by name
+func (t Template) GetParameter(name string) (*yaml.Node, error) {
+	return t.GetNode(Parameters, name)
+}
+
+// GetNode returns a yaml node by section and name
+func (t Template) GetNode(section Section, name string) (*yaml.Node, error) {
+	_, resMap := s11n.GetMapValue(t.Node.Content[0], string(section))
+	if resMap == nil {
+		return nil, fmt.Errorf("unable to locate the %s node", section)
+	}
+	// TODO: Some Sections are not Maps
+	_, resource := s11n.GetMapValue(resMap, name)
+	if resource == nil {
+		return nil, fmt.Errorf("unable to locate %s %s", section, name)
+	}
+	return resource, nil
+}
diff --git a/docs/bash_completion.sh b/docs/bash_completion.sh
index c2689ee9..366864f4 100644
--- a/docs/bash_completion.sh
+++ b/docs/bash_completion.sh
@@ -459,10 +459,6 @@ _rain_cat()
     flags_with_completion=()
     flags_completion=()
 
-    flags+=("--config")
-    flags+=("-c")
-    local_nonpersistent_flags+=("--config")
-    local_nonpersistent_flags+=("-c")
     flags+=("--help")
     flags+=("-h")
     local_nonpersistent_flags+=("--help")
diff --git a/docs/index.md b/docs/index.md
index a3f64a77..c3438246 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -36,4 +36,4 @@ Rain is a command line tool for working with AWS CloudFormation templates and st
 * [rain tree](rain_tree.md)	 - Find dependencies of Resources and Outputs in a local template
 * [rain watch](rain_watch.md)	 - Display an updating view of a CloudFormation stack
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_bootstrap.md b/docs/rain_bootstrap.md
index 9c35d8e1..d0dda6bb 100644
--- a/docs/rain_bootstrap.md
+++ b/docs/rain_bootstrap.md
@@ -30,4 +30,4 @@ rain bootstrap
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_build.md b/docs/rain_build.md
index 60fad612..1c5658dc 100644
--- a/docs/rain_build.md
+++ b/docs/rain_build.md
@@ -31,4 +31,4 @@ rain build [<resource type> or <prompt>]
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_cat.md b/docs/rain_cat.md
index 9ae9e5c4..73b8a801 100644
--- a/docs/rain_cat.md
+++ b/docs/rain_cat.md
@@ -4,10 +4,7 @@ Get the CloudFormation template from a running stack
 
 ### Synopsis
 
-Downloads the template or the configuration file used to deploy <stack> and prints it to stdout.
-
-The  `--config` flag can be used to get the rain config file for the stack instead of the template.
-
+Downloads the template used to deploy <stack> and prints it to stdout.
 
 ```
 rain cat <stack>
@@ -16,7 +13,6 @@ rain cat <stack>
 ### Options
 
 ```
-  -c, --config           output the config file for the existing stack
   -h, --help             help for cat
   -p, --profile string   AWS profile name; read from the AWS CLI configuration file
   -r, --region string    AWS region to use
@@ -35,4 +31,4 @@ rain cat <stack>
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_ccdeploy.md b/docs/rain_ccdeploy.md
index b28e7f21..c5afa180 100644
--- a/docs/rain_ccdeploy.md
+++ b/docs/rain_ccdeploy.md
@@ -37,4 +37,4 @@ rain ccdeploy <template> <name>
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_ccrm.md b/docs/rain_ccrm.md
index 4d35b6f8..ab7d6de3 100644
--- a/docs/rain_ccrm.md
+++ b/docs/rain_ccrm.md
@@ -35,4 +35,4 @@ rain ccrm <name>
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_console.md b/docs/rain_console.md
index 9c15b024..cdc3156a 100644
--- a/docs/rain_console.md
+++ b/docs/rain_console.md
@@ -38,4 +38,4 @@ rain console [stack]
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_deploy.md b/docs/rain_deploy.md
index eba66548..67d52a96 100644
--- a/docs/rain_deploy.md
+++ b/docs/rain_deploy.md
@@ -70,4 +70,4 @@ rain deploy <template> [stack]
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_diff.md b/docs/rain_diff.md
index 801bfef6..3e7e4833 100644
--- a/docs/rain_diff.md
+++ b/docs/rain_diff.md
@@ -28,4 +28,4 @@ rain diff <from> <to>
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_fmt.md b/docs/rain_fmt.md
index 11129072..f662ff1f 100644
--- a/docs/rain_fmt.md
+++ b/docs/rain_fmt.md
@@ -32,4 +32,4 @@ rain fmt <filename>...
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_forecast.md b/docs/rain_forecast.md
index 0c738ada..6a14c4cf 100644
--- a/docs/rain_forecast.md
+++ b/docs/rain_forecast.md
@@ -59,4 +59,4 @@ rain forecast --experimental <template> [stackName]
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_info.md b/docs/rain_info.md
index 9f8aaf1c..eac705e2 100644
--- a/docs/rain_info.md
+++ b/docs/rain_info.md
@@ -30,4 +30,4 @@ rain info
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_logs.md b/docs/rain_logs.md
index b6b3f77a..e7873eb9 100644
--- a/docs/rain_logs.md
+++ b/docs/rain_logs.md
@@ -36,4 +36,4 @@ rain logs <stack> (<resource>)
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_ls.md b/docs/rain_ls.md
index 204a4bf9..2d4b69d5 100644
--- a/docs/rain_ls.md
+++ b/docs/rain_ls.md
@@ -30,4 +30,4 @@ rain ls <stack>
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_merge.md b/docs/rain_merge.md
index 71c23336..930bb047 100644
--- a/docs/rain_merge.md
+++ b/docs/rain_merge.md
@@ -29,4 +29,4 @@ rain merge <template> <template> ...
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_pkg.md b/docs/rain_pkg.md
index 6ec733cf..60272103 100644
--- a/docs/rain_pkg.md
+++ b/docs/rain_pkg.md
@@ -66,4 +66,4 @@ rain pkg <template>
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_rm.md b/docs/rain_rm.md
index 0f759a80..12fb592a 100644
--- a/docs/rain_rm.md
+++ b/docs/rain_rm.md
@@ -32,4 +32,4 @@ rain rm <stack>
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_stackset.md b/docs/rain_stackset.md
index 31fd4992..bcb9d1a3 100644
--- a/docs/rain_stackset.md
+++ b/docs/rain_stackset.md
@@ -28,4 +28,4 @@ This command manipulates stack sets. It has no action if specific stack set comm
 * [rain stackset ls](rain_stackset_ls.md)	 - List a CloudFormation stack sets in a given region
 * [rain stackset rm](rain_stackset_rm.md)	 - Delete a CloudFormation stack set and/or its instances.
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_stackset_deploy.md b/docs/rain_stackset_deploy.md
index ef7e3800..3ee665f5 100644
--- a/docs/rain_stackset_deploy.md
+++ b/docs/rain_stackset_deploy.md
@@ -64,4 +64,4 @@ rain stackset deploy <template> [stackset] [flags]
 
 * [rain stackset](rain_stackset.md)	 - This command manipulates stack sets.
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_stackset_ls.md b/docs/rain_stackset_ls.md
index 597a8004..fd81bfb6 100644
--- a/docs/rain_stackset_ls.md
+++ b/docs/rain_stackset_ls.md
@@ -30,4 +30,4 @@ rain stackset ls <stack set>
 
 * [rain stackset](rain_stackset.md)	 - This command manipulates stack sets.
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_stackset_rm.md b/docs/rain_stackset_rm.md
index c8703f76..7cff1961 100644
--- a/docs/rain_stackset_rm.md
+++ b/docs/rain_stackset_rm.md
@@ -30,4 +30,4 @@ rain stackset rm <stackset>
 
 * [rain stackset](rain_stackset.md)	 - This command manipulates stack sets.
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_tree.md b/docs/rain_tree.md
index d719e881..7787c025 100644
--- a/docs/rain_tree.md
+++ b/docs/rain_tree.md
@@ -30,4 +30,4 @@ rain tree [template]
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/docs/rain_watch.md b/docs/rain_watch.md
index 688e82b1..2d9f7b1c 100644
--- a/docs/rain_watch.md
+++ b/docs/rain_watch.md
@@ -30,4 +30,4 @@ rain watch <stack>
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 16-Jan-2024
+###### Auto generated by spf13/cobra on 13-Dec-2023
diff --git a/go.mod b/go.mod
index b59ed7b6..00ef4424 100644
--- a/go.mod
+++ b/go.mod
@@ -26,9 +26,20 @@ require (
 	gopkg.in/yaml.v3 v3.0.1
 )
 
+require (
+	github.com/fatih/color v1.16.0
+	github.com/mattn/go-runewidth v0.0.15
+	github.com/stretchr/testify v1.8.4
+)
+
 require (
 	github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect
 	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/rivo/uniseg v0.2.0 // indirect
 )
 
 require (
diff --git a/go.sum b/go.sum
index 0c1d27e8..2bffe902 100644
--- a/go.sum
+++ b/go.sum
@@ -59,6 +59,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/evanphx/json-patch v4.0.0+incompatible h1:xregGRMLBeuRcwiOTHRCsPPuzCQlqhxUPbqdw+zNkLc=
 github.com/evanphx/json-patch v4.0.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
+github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
+github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
@@ -71,10 +73,19 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
 github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
 github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
 github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
+github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d h1:NqRhLdNVlozULwM1B3VaHhcXYSgrOAv8V5BE65om+1Q=
 github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d/go.mod h1:cxIIfNMTwff8f/ZvRouvWYF6wOoO7nj99neWSx2q/Es=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
@@ -84,11 +95,14 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
 golang.org/x/exp v0.0.0-20231214170342-aacd6d4b4611 h1:qCEDpW1G+vcj3Y7Fy52pEM1AWm3abj8WimGYejI3SC4=
 golang.org/x/exp v0.0.0-20231214170342-aacd6d4b4611/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
 golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
 golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
diff --git a/internal/aws/ccapi/ccapi.go b/internal/aws/ccapi/ccapi.go
index bfc42d83..44e350fb 100644
--- a/internal/aws/ccapi/ccapi.go
+++ b/internal/aws/ccapi/ccapi.go
@@ -93,12 +93,12 @@ func CreateResource(logicalId string, resource *yaml.Node) (identifier string, m
 	}
 	output, err := getClient().CreateResource(context.Background(), &input)
 
-	config.Debugf("CreateResource output:\n%v", printProgress(output.ProgressEvent))
-
 	if err != nil {
 		return identifier, model, err
 	}
 
+	config.Debugf("CreateResource output:\n%v", printProgress(output.ProgressEvent))
+
 	progress := output.ProgressEvent
 	identifier, model, err = pollForCompletion(progress)
 	if err != nil {
@@ -316,6 +316,9 @@ func DeleteResource(logicalId string, identifier string, resource *yaml.Node) er
 	if identifier == "" {
 		return fmt.Errorf("identifier is blank for DeleteResource %v", logicalId)
 	}
+	if resource == nil {
+		return fmt.Errorf("resource is nil for %v %v", logicalId, identifier)
+	}
 
 	clientToken := uuid.New().String()
 
diff --git a/internal/aws/cfn/cfn.go b/internal/aws/cfn/cfn.go
index fd9b4553..1d01a86d 100644
--- a/internal/aws/cfn/cfn.go
+++ b/internal/aws/cfn/cfn.go
@@ -1001,6 +1001,7 @@ func GetTypeIdentifier(name string) ([]string, error) {
 
 // Get the values specified for primary identifiers in the template.
 // The return value will only have values if they are set.
+// TODO: Use ccapi to look at the deployed resource model for updates
 func GetPrimaryIdentifierValues(
 	primaryIdentifier []string,
 	resource *yaml.Node,
@@ -1051,6 +1052,7 @@ func GetPrimaryIdentifierValues(
 
 // resolveRef resolves a scalar reference if we have enough information
 // Returns "", error if the Ref can't be resolved (not a panic condition)
+// TODO: ccdeploy.resolve does this better
 func resolveRef(name string, template *yaml.Node, dc *dc.DeployConfig) (string, error) {
 	_, params := s11n.GetMapValue(template.Content[0], "Parameters")
 	config.Debugf("resolveRef params: %v", node.ToJson(params))
diff --git a/internal/cmd/cat/cat.go b/internal/cmd/cat/cat.go
index 520eff9d..9683a1a0 100644
--- a/internal/cmd/cat/cat.go
+++ b/internal/cmd/cat/cat.go
@@ -2,50 +2,29 @@ package cat
 
 import (
 	"fmt"
+
 	"github.com/aws-cloudformation/rain/cft/format"
+	"github.com/aws-cloudformation/rain/internal/ui"
+
 	"github.com/aws-cloudformation/rain/cft/parse"
 	"github.com/aws-cloudformation/rain/internal/aws/cfn"
 	"github.com/aws-cloudformation/rain/internal/console/spinner"
-	"github.com/aws-cloudformation/rain/internal/dc"
-	"github.com/aws-cloudformation/rain/internal/ui"
 	"github.com/spf13/cobra"
 )
 
 var transformed = false
 var unformatted = false
-var config = false
 
 // Cmd is the cat command's entrypoint
 var Cmd = &cobra.Command{
-	Use:   "cat <stack>",
-	Short: "Get the CloudFormation template from a running stack",
-	Long: `Downloads the template or the configuration file used to deploy <stack> and prints it to stdout.
-
-The  ` + "`" + `--config` + "`" + ` flag can be used to get the rain config file for the stack instead of the template.
-`,
+	Use:                   "cat <stack>",
+	Short:                 "Get the CloudFormation template from a running stack",
+	Long:                  "Downloads the template used to deploy <stack> and prints it to stdout.",
 	Args:                  cobra.ExactArgs(1),
 	DisableFlagsInUseLine: true,
 	Run: func(cmd *cobra.Command, args []string) {
 		stackName := args[0]
 
-		// Output the config file if requested instead of the template
-		if config {
-			spinner.Push(fmt.Sprintf("Getting config from stack '%s'", stackName))
-			stack, err := cfn.GetStack(stackName)
-			if err != nil {
-				panic(ui.Errorf(err, "failed to get stack '%s'", stackName))
-			}
-			spinner.Pop()
-
-			deployedConfig, err := dc.ConfigFromStack(stack)
-			if err != nil {
-				panic(ui.Errorf(err, "unable to get configuration for stack : '%s'", stackName))
-			}
-			fmt.Print(deployedConfig)
-
-			return
-		}
-
 		spinner.Push(fmt.Sprintf("Getting template from stack '%s'", stackName))
 		template, err := cfn.GetStackTemplate(stackName, transformed)
 		if err != nil {
@@ -69,5 +48,4 @@ The  ` + "`" + `--config` + "`" + ` flag can be used to get the rain config file
 func init() {
 	Cmd.Flags().BoolVarP(&transformed, "transformed", "t", false, "get the template with transformations applied by CloudFormation")
 	Cmd.Flags().BoolVarP(&unformatted, "unformatted", "u", false, "output the template in its raw form; do not attempt to format it")
-	Cmd.Flags().BoolVarP(&config, "config", "c", false, "output the config file for the existing stack")
 }
diff --git a/internal/cmd/ccdeploy/README.md b/internal/cmd/cc/README.md
similarity index 91%
rename from internal/cmd/ccdeploy/README.md
rename to internal/cmd/cc/README.md
index 070f205c..37a5c97b 100644
--- a/internal/cmd/ccdeploy/README.md
+++ b/internal/cmd/cc/README.md
@@ -1,6 +1,6 @@
-# ccdeploy
+# Cloud Control API Deployments 
 
-The `rain ccdeploy` command provisions resources using the AWS Cloud Control
+The `rain cc deploy` command provisions resources using the AWS Cloud Control
 API (CCAPI). It does not submit templates to CloudFormation, so there will be
 no managed stack to interact with after running this command. API calls are
 made directly from the client to CCAPI, and the state for the resources is
@@ -16,7 +16,7 @@ provisioning is still done by the same back-end resource providers that
 CloudFormation uses. Those are available on GitHub under the
 `aws-cloudformation` organization, for example
 [RDS](https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-rds).
-The `ccdeploy` command makes client-side calls to CCAPI endpoints like `CreateResource`, 
+The `cc deploy` command makes client-side calls to CCAPI endpoints like `CreateResource`, 
 but then CCAPI itself is the one making SDK calls into specific services.
 
 The following is an excerpt from a blog post: [The history and future roadmap
@@ -79,7 +79,7 @@ It is obviously very important to store the state in a way that it cannot be
 lost or associated with the wrong AWS environment. It is also important to make
 sure two processes don't try to deploy the same template at the same time.
 
-The state file for `rain ccdeploy` is stored as a YAML file that has the same
+The state file for `rain cc deploy` is stored as a YAML file that has the same
 format as the source CloudFormation template. Extra sections are added to the
 file to associate state with the deployed resources.
 
@@ -114,13 +114,13 @@ properties and compare them to the stored state.
 To use this command, supply the same arguments that you would supply to the `deploy` command:
 
 ```sh
-$ rain ccdeploy my-template.yaml my-deployment-name
+$ rain cc deploy my-template.yaml my-deployment-name
 ```
 
-To remove resources deployed with `ccdeploy`, use the `ccrm` command:
+To remove resources deployed with `cc deploy`, use the `cc rm` command:
 
 ```sh
-$ rain ccrm my-deployment-name
+$ rain cc rm my-deployment-name
 ```
 
 ## Unsupported features
diff --git a/internal/cmd/cc/cc.go b/internal/cmd/cc/cc.go
new file mode 100644
index 00000000..189d6b6f
--- /dev/null
+++ b/internal/cmd/cc/cc.go
@@ -0,0 +1,42 @@
+package cc
+
+import (
+	"fmt"
+
+	"github.com/aws-cloudformation/rain/cft"
+	"github.com/aws-cloudformation/rain/internal/dc"
+	"github.com/spf13/cobra"
+)
+
+// Args
+var params []string
+var tags []string
+var configFilePath string
+var Experimental bool
+var yes bool
+var ignoreUnknownParams bool
+var downloadState bool
+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 Cmd = &cobra.Command{
+	Use:   "cc <command>",
+	Short: "Interact with templates using Cloud Control API instead of CloudFormation",
+	Long: `You must pass the --experimental (-x) flag to use this command, to acknowledge that it is experimental and likely to be unstable!
+`,
+	Run: run,
+}
+
+func run(cmd *cobra.Command, args []string) {
+	fmt.Println("Usage: cc deploy|rm|state")
+}
+
+func init() {
+	Cmd.AddCommand(CCDeployCmd)
+	Cmd.AddCommand(CCRmCmd)
+	Cmd.AddCommand(CCStateCmd)
+}
diff --git a/internal/cmd/cc/deploy.go b/internal/cmd/cc/deploy.go
new file mode 100644
index 00000000..0c099204
--- /dev/null
+++ b/internal/cmd/cc/deploy.go
@@ -0,0 +1,166 @@
+package cc
+
+import (
+	"errors"
+	"fmt"
+	"path/filepath"
+
+	"github.com/aws-cloudformation/rain/cft"
+	"github.com/aws-cloudformation/rain/cft/format"
+	"github.com/aws-cloudformation/rain/cft/pkg"
+	"github.com/aws-cloudformation/rain/internal/aws/s3"
+	"github.com/aws-cloudformation/rain/internal/cmd/forecast"
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/aws-cloudformation/rain/internal/console"
+	"github.com/aws-cloudformation/rain/internal/console/spinner"
+	"github.com/aws-cloudformation/rain/internal/dc"
+	"github.com/aws-cloudformation/rain/internal/ui"
+	"github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
+	"github.com/spf13/cobra"
+)
+
+// PackageTemplate reads the template and performs any necessary packaging on it
+// before deployment. The rain bucket will be created if it does not already exist.
+func PackageTemplate(fn string, yes bool) cft.Template {
+
+	t, err := pkg.File(fn)
+	if err != nil {
+		panic(ui.Errorf(err, "error packaging template '%s'", fn))
+	}
+
+	return t
+}
+
+func deploy(cmd *cobra.Command, args []string) {
+	fn := args[0]
+	name := args[1]
+	base := filepath.Base(fn)
+	absPath, _ := filepath.Abs(fn)
+
+	if !Experimental {
+		panic("Please add the --experimental arg to use this feature")
+	}
+
+	// Call RainBucket for side-effects in case we want to force bucket creation
+	bucketName := s3.RainBucket(true)
+
+	// Package template
+	spinner.Push(fmt.Sprintf("Preparing template '%s'", base))
+	template := PackageTemplate(fn, true)
+	spinner.Pop()
+
+	// Get parameters and tags
+	stack := types.Stack{} // Not relevant here
+	stack.Parameters = make([]types.Parameter, 0)
+	dc, err := dc.GetDeployConfig(tags, params, configFilePath, base,
+		template, stack, false, yes, ignoreUnknownParams)
+	if err != nil {
+		panic(err)
+	}
+	templateConfig = dc
+
+	// Compare against the current state to see what has changed, if this is an update
+	spinner.Push("Checking state")
+	stateResult, stateError := checkState(name, template, bucketName, "", absPath, unlock)
+	if stateError != nil {
+		panic(stateError)
+	}
+	spinner.Pop()
+
+	config.Debugf("StateFile:\n%v", format.String(stateResult.StateFile,
+		format.Options{JSON: false, Unsorted: false}))
+
+	var changes cft.Template
+
+	if stateResult.IsUpdate {
+		var err error
+		changes, err = update(stateResult.StateFile, template)
+		if err != nil {
+			panic(err)
+		}
+		// TODO: update needs to take parameters into account
+
+	} else {
+		// Deploy the provided template for the first time
+		changes = template
+	}
+
+	summarizeChanges(changes)
+
+	if !console.Confirm(true, "Do you wish to continue?") {
+		// Unlock the state file
+		if !stateResult.IsUpdate {
+			err := deleteState(name, bucketName)
+			if err != nil {
+				panic(fmt.Errorf("unable to remove state file: %v", err))
+			}
+		} else {
+			err := writeState(template, nil, bucketName, name, absPath)
+			if err != nil {
+				panic(fmt.Errorf("unable to unlock state file: %v", err))
+			}
+		}
+
+		// Exit
+		panic(errors.New("user cancelled deployment"))
+	}
+
+	// Set the global reference that anything in this package can access
+	deployedTemplate = changes
+
+	// Figure out how long we thing the stack will take to execute
+	totalSeconds := forecast.PredictTotalEstimate(changes, stateResult.IsUpdate)
+	// TODO: Forecast can be more accurate here since we know the actions
+	fmt.Printf("Predicted deployment time: %v\n", forecast.FormatEstimate(totalSeconds))
+
+	spinner.StartTimer(fmt.Sprintf("Deploying %v", name))
+	results, err := DeployTemplate(changes)
+	if err != nil {
+		// An unexpected error that prevented deployment from starting
+		panic(err)
+	}
+	spinner.StopTimer()
+
+	results.Summarize()
+
+	if !results.Succeeded {
+		panic("Deployment failed! The state file is locked and will need to be resolved manually.")
+
+		// Leave the state file locked. Needs to be resolved manually.
+	} else {
+		fmt.Println("Deployment completed successfully!")
+
+		// Unlock the state file and record current values
+		err := writeState(template, results, bucketName, name, absPath)
+		if err != nil {
+			panic(fmt.Errorf("unable to write state file: %v", err))
+		}
+	}
+
+}
+
+var CCDeployCmd = &cobra.Command{
+	Use:   "deploy <template> <name>",
+	Short: "Deploy a local template directly using the Cloud Control API (Experimental!)",
+	Long: `Creates or updates resources directly using Cloud Control API from the template file <template>.
+You must pass the --experimental (-x) flag to use this command, to acknowledge that it is experimental and likely to be unstable!
+`,
+	Args:                  cobra.ExactArgs(2),
+	DisableFlagsInUseLine: true,
+	Run:                   deploy,
+}
+
+func init() {
+	CCDeployCmd.Flags().BoolVar(&config.Debug, "debug", false, "Output debugging information")
+	CCDeployCmd.Flags().BoolVarP(&Experimental, "experimental", "x", false, "Acknowledge that this is an experimental feature")
+	CCDeployCmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask questions; just deploy")
+	CCDeployCmd.Flags().BoolVarP(&downloadState, "state", "s", false, "Instead of deploying, download the state file")
+	CCDeployCmd.Flags().StringSliceVar(&tags, "tags", []string{}, "add tags to the stack; use the format key1=value1,key2=value2")
+	CCDeployCmd.Flags().StringSliceVar(&params, "params", []string{}, "set parameter values; use the format key1=value1,key2=value2")
+	CCDeployCmd.Flags().StringVarP(&configFilePath, "config", "c", "", "YAML or JSON file to set tags and parameters")
+	CCDeployCmd.Flags().StringVarP(&unlock, "unlock", "u", "", "Unlock <lockid> and continue")
+	CCDeployCmd.Flags().BoolVarP(&ignoreUnknownParams, "ignore-unknown-params", "", false, "Ignore unknown parameters")
+
+	resMap = make(map[string]*Resource)
+
+}
diff --git a/internal/cmd/ccdeploy/deployment.go b/internal/cmd/cc/deployment.go
similarity index 74%
rename from internal/cmd/ccdeploy/deployment.go
rename to internal/cmd/cc/deployment.go
index 41a4be3c..82629cc3 100644
--- a/internal/cmd/ccdeploy/deployment.go
+++ b/internal/cmd/cc/deployment.go
@@ -1,19 +1,32 @@
-package ccdeploy
+package cc
 
 import (
+	"encoding/json"
+	"errors"
 	"fmt"
 	"time"
 
 	"github.com/aws-cloudformation/rain/cft"
 	"github.com/aws-cloudformation/rain/cft/diff"
+	"github.com/aws-cloudformation/rain/cft/format"
 	"github.com/aws-cloudformation/rain/cft/graph"
 	"github.com/aws-cloudformation/rain/internal/aws/ccapi"
 	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/aws-cloudformation/rain/internal/console"
+	"github.com/aws-cloudformation/rain/internal/console/spinner"
 	"github.com/aws-cloudformation/rain/internal/node"
 	"github.com/aws-cloudformation/rain/internal/s11n"
+	"github.com/aws-cloudformation/rain/internal/table"
+	"github.com/fatih/color"
 	"gopkg.in/yaml.v3"
 )
 
+var createFormat table.Formatter
+var updateFormat table.Formatter
+var deleteFormat table.Formatter
+var failFormat table.Formatter
+var successFormat table.Formatter
+
 // getTemplateResource returns the yaml node based on the logical id
 func getTemplateResource(template cft.Template, logicalId string) (*yaml.Node, error) {
 	rootMap := template.Node.Content[0]
@@ -38,14 +51,28 @@ func deployResource(resource *Resource) {
 	config.Debugf("Deploying %v...", resource)
 
 	resource.State = Deploying
+	resource.Start = time.Now()
+	defer func() { resource.End = time.Now() }()
 
-	// TODO - Resolve instrinsics before creating the resource
-	// This will depend on the post-deployment state of dependencies
-	resolvedNode, err := resolve(resource)
-	if err != nil {
-		config.Debugf("deployResource resolve failed: %v", err)
-		resource.State = Failed
-		resource.Message = fmt.Sprintf("%v", err)
+	// Resolve instrinsics before creating the resource.
+	// This depends on the post-deployment state of dependencies
+	var resolvedNode *yaml.Node
+	var err error
+	if resource.Action == diff.Delete {
+		// We don't need to resolve resources we are deleting.
+		resolvedNode = resource.Node
+	} else {
+		resolvedNode, err = Resolve(resource)
+		// TODO: Failing due to empty Model on updates
+		// If the dependencies didn't change, they don't get deployed,
+		// so we need to get the Model from the state file.
+		// Or.. query ccapi for the live state?
+		if err != nil {
+			config.Debugf("deployResource resolve failed: %v", err)
+			resource.State = Failed
+			resource.Message = fmt.Sprintf("%v", err)
+			return
+		}
 	}
 
 	switch resource.Action {
@@ -75,7 +102,7 @@ func deployResource(resource *Resource) {
 		// 	resource.Message = fmt.Sprintf("%v", err)
 		// }
 		//
-		// We would need that at an earlier state for drift detection
+		// We would need that at an earlier step for drift detection
 
 		priorJson := resource.PriorJson
 
@@ -107,9 +134,12 @@ func deployResource(resource *Resource) {
 
 	default:
 		// None means this is an update with no change to the model
-		config.Debugf("deployResource not deploying unchanged %v", resource.Name)
+		config.Debugf("deployResource not deploying unchanged %v. Identifier: %v, Model: %v",
+			resource.Name, resource.Identifier, resource.Model)
 		resource.State = Deployed
 		resource.Message = "Success"
+
+		// TODO: Are we missing the Model here?
 	}
 
 }
@@ -158,6 +188,80 @@ type DeploymentResults struct {
 	Resources map[string]*Resource
 }
 
+// Summarize prints out a summary of deployment results
+func (results *DeploymentResults) Summarize() {
+
+	fmt.Println("Deployment results summary")
+	fmt.Println()
+
+	tbl := table.New("Action", "Message", "Type", "LogicalId", "Identifier")
+	headerFmt := color.New(color.FgBlue, color.Underline).SprintfFunc()
+	tbl.WithHeaderFormatter(headerFmt)
+
+	failureMessages := make([]string, 0)
+	for _, resource := range results.Resources {
+		var action, message, t, logicalId, ident string
+		var formatter table.Formatter
+
+		switch resource.Action {
+		case diff.Create:
+			action = "Created"
+			if resource.State == Failed {
+				action = "Create"
+			}
+		case diff.Update:
+			action = "Updated"
+			if resource.State == Failed {
+				action = "Update"
+			}
+		case diff.Delete:
+			action = "Deleted"
+			if resource.State == Failed {
+				action = "Delete"
+			}
+		default:
+			action = "None"
+			formatter = nil
+		}
+		if resource.State == Failed {
+			formatter = failFormat
+		} else {
+			formatter = successFormat
+		}
+		// state = stateIcons[resource.State]
+		switch resource.State {
+		case Waiting:
+			message = "Waiting"
+		case Deploying:
+			message = "Deploying"
+		case Failed:
+			message = "Failed"
+			msg := fmt.Sprintf("%s: %s", resource.Name, resource.Message)
+			failureMessages = append(failureMessages, msg)
+		case Deployed:
+			message = "Success"
+		case Canceled:
+			message = "Canceled"
+		}
+		if action == "None" {
+			message = ""
+			formatter = nil
+		}
+		t = resource.Type
+		logicalId = resource.Name
+		ident = resource.Identifier
+
+		tbl.AddRowf(formatter, action, message, t, logicalId, ident)
+	}
+	tbl.Print()
+	fmt.Println()
+	if len(failureMessages) > 0 {
+		for _, m := range failureMessages {
+			fmt.Println(console.Red(m))
+		}
+	}
+}
+
 // canDelete returns true if the resource can be deleted.
 // This is not the same as the ready function, which tells
 // you if it can be deleted "right now". This function looks
@@ -350,6 +454,7 @@ func DeployTemplate(template cft.Template) (*DeploymentResults, error) {
 				// Assume this is a new deployment
 				action = diff.Create
 			} else {
+				config.Debugf("DeployTemplate stateNode: %v", node.ToSJson(stateNode))
 				for i, s := range stateNode.Content {
 					if i%2 == 0 {
 						if s.Value == "Action" {
@@ -366,7 +471,9 @@ func DeployTemplate(template cft.Template) (*DeploymentResults, error) {
 						} else if s.Value == "Identifier" {
 							ident = stateNode.Content[i+1].Value
 						} else if s.Value == "ResourceModel" {
-							model = stateNode.Content[i+1].Value
+							j := format.Jsonise(stateNode.Content[i+1])
+							m, _ := json.Marshal(j)
+							model = string(m)
 						} else if s.Value == "PriorJson" {
 							priorJson = stateNode.Content[i+1].Value
 						} else {
@@ -383,6 +490,7 @@ func DeployTemplate(template cft.Template) (*DeploymentResults, error) {
 			r.PriorJson = priorJson // We need this for ccapi update
 
 			config.Debugf("deployment set r.Model to %v", r.Model)
+			// TODO: This is blank when we update
 
 			if r.Action == diff.Delete {
 				deletes = append(deletes, r)
@@ -407,7 +515,11 @@ func DeployTemplate(template cft.Template) (*DeploymentResults, error) {
 		return nil, err
 	}
 	if !results.Succeeded {
-		return nil, fmt.Errorf("unable to delete resources: %v", err)
+		spinner.StopTimer()
+		for _, resource := range results.Resources {
+			fmt.Printf("%v\n", resource)
+		}
+		return nil, errors.New("unable to delete resources")
 	}
 
 	// Deploy the rest of the resources
@@ -419,3 +531,11 @@ func DeployTemplate(template cft.Template) (*DeploymentResults, error) {
 	return results, nil
 
 }
+
+func init() {
+	createFormat = color.New(color.FgCyan).SprintfFunc()
+	updateFormat = color.New(color.FgYellow).SprintfFunc()
+	deleteFormat = color.New(color.FgMagenta).SprintfFunc()
+	failFormat = color.New(color.FgRed).Add(color.Bold).SprintfFunc()
+	successFormat = color.New(color.FgGreen).SprintfFunc()
+}
diff --git a/internal/cmd/cc/parse_sub.go b/internal/cmd/cc/parse_sub.go
new file mode 100644
index 00000000..236d8fd3
--- /dev/null
+++ b/internal/cmd/cc/parse_sub.go
@@ -0,0 +1,139 @@
+package cc
+
+import (
+	"errors"
+	"strings"
+
+	"github.com/aws-cloudformation/rain/internal/config"
+)
+
+const (
+	DATA   rune = ' ' // Any other rune
+	DOLLAR rune = '$'
+	OPEN   rune = '{'
+	CLOSE  rune = '}'
+	BANG   rune = '!'
+)
+
+type wordtype int
+
+const (
+	STR    wordtype = iota // A literal string fragment
+	REF                    // ${ParamOrResourceName}
+	AWS                    // ${AWS::X}
+	GETATT                 // ${X.Y}
+)
+
+type word struct {
+	t wordtype
+	w string // Does not include the ${} if it's not a STR
+}
+
+type state int
+
+const (
+	READSTR state = iota
+	MAYBE
+	READVAR
+	READLIT
+)
+
+// ParseSub returns a slice of words, based on a string
+// argument to the Fn::Sub intrinsic function.
+//
+// "ABC-${XYZ}-123"
+//
+// returns a slice containing:
+//
+//	word { t: STR, w: "ABC-" }
+//	word { t: REF, w: "XYZ" }
+//	word { t: STR, w: "-123" }
+//
+// Invalid syntax like "${AAA" returns an error
+func ParseSub(sub string) ([]word, error) {
+	words := make([]word, 0)
+	state := READSTR
+	var last rune
+	var buf string
+	var wt wordtype
+	for i, r := range sub {
+		config.Debugf("%#U", r)
+		switch r {
+		case DOLLAR:
+			if state != READVAR {
+				state = MAYBE
+			} else {
+				// This is a literal $ inside a variable: "${AB$C}"
+				// TODO: Should that be an error? Is it valid?
+				buf += string(r)
+			}
+		case OPEN:
+			if state == MAYBE {
+				// Peek to see if we're about to start a LITERAL !
+				if len(sub) > i+1 && []rune(sub)[i+1] == BANG {
+					// Treat this as part of the string, not a var
+					buf += "${"
+					state = READLIT
+				} else {
+					state = READVAR
+					// We're about to start reading a variable.
+					// Append the last word in the buffer if it's not empty
+					if len(buf) > 0 {
+						wt = STR
+						words = append(words, word{t: wt, w: buf})
+						buf = ""
+					}
+				}
+			} else {
+				buf += string(r)
+			}
+		case CLOSE:
+			if state == READVAR {
+				// Figure out what type it is
+				if strings.HasPrefix(buf, "AWS::") {
+					wt = AWS
+				} else if strings.Contains(buf, ".") {
+					wt = GETATT
+				} else {
+					wt = REF
+				}
+				buf = strings.Replace(buf, "AWS::", "", 1)
+				words = append(words, word{t: wt, w: buf})
+				buf = ""
+				state = READSTR
+			} else {
+				buf += string(r)
+			}
+		case BANG:
+			// ${!LITERAL} becomes ${LITERAL}
+			if state == READLIT {
+				// Don't write the ! to the string
+				state = READSTR
+			} else {
+				// This is a ! somewhere not related to a LITERAL
+				buf += string(r)
+			}
+		default:
+			if state == MAYBE {
+				buf += string(last) // Put the $ back on the buffer
+				state = READSTR
+			}
+			buf += string(r)
+		}
+		last = r
+	}
+
+	if len(buf) > 0 {
+		wt = STR
+		words = append(words, word{t: wt, w: buf})
+		buf = ""
+	}
+
+	// Handle malformed strings, like "ABC${XYZ"
+	if state != READSTR {
+		// Ended the string in the middle of a variable?
+		return nil, errors.New("invalid string, unclosed variable")
+	}
+
+	return words, nil
+}
diff --git a/internal/cmd/cc/resolve.go b/internal/cmd/cc/resolve.go
new file mode 100644
index 00000000..743f9260
--- /dev/null
+++ b/internal/cmd/cc/resolve.go
@@ -0,0 +1,414 @@
+package cc
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/aws-cloudformation/rain/internal/aws"
+	"github.com/aws-cloudformation/rain/internal/aws/sts"
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/aws-cloudformation/rain/internal/node"
+	"gopkg.in/yaml.v3"
+)
+
+// resolve resolves CloudFormation intrinsic functions
+//
+// It relies on dependent resources already having been deployed,
+// so that we can query values with CCAPI.
+//
+// Supported:
+//
+//	Ref
+//	Fn::GetAtt
+//	Fn::Sub
+//
+// Not Supported:
+//
+//	Fn::Base64
+//	Fn::Cidr
+//	Condition functions
+//	Fn::FindInMap
+//	Fn::ForEach
+//	Fn::GetAZs
+//	Fn::ImportValue
+//	Fn::Join
+//	Fn::Length
+//	Fn::Select
+//	Fn::Split
+//	Fn::ToJsonString
+//	Fn::Transform
+func Resolve(resource *Resource) (*yaml.Node, error) {
+	return resolveNode(resource.Node, resource)
+}
+
+const AWS_PREFIX = "AWS::"
+
+// TODO: What about intrinsics outside of Resources?
+
+// resolveNode is a recursive function that resolves all
+// intrinsics in the resource node and its children
+func resolveNode(n *yaml.Node, resource *Resource) (*yaml.Node, error) {
+
+	config.Debugf("resolveNode: %s", node.ToSJson(n))
+
+	if n.Kind != yaml.MappingNode {
+		return nil, errors.New("expected resource node to be a Mapping")
+	}
+
+	// We'll return a clone of the node, with intrinsics resolved
+	retval := node.Clone(n)
+
+	for i := 0; i < len(n.Content); i += 2 {
+		mapkey := n.Content[i]
+		mapval := n.Content[i+1]
+		if mapkey.Kind == yaml.ScalarNode && mapkey.Value == "Ref" {
+			config.Debugf("This is a Ref")
+			refVal, err := resolveRef(mapval, resource)
+			if err != nil {
+				return nil, err
+			}
+
+			/* We need to convert the entire Mapping node to a scalar
+
+			n is:
+
+			{
+			  "Kind": "Mapping",
+			  "Value": "",
+			  "Content": [
+				{
+				  "Kind": "Scalar",
+				  "Value": "Ref"
+				},
+				{
+				  "Kind": "Scalar",
+				  "Value": "aaa"
+				}
+			  ]
+			}
+
+			n needs to be:
+
+			{
+			  "Kind": "Scalar",
+			  "Value": "aaa"
+			}
+
+			*/
+
+			retval = &yaml.Node{Kind: yaml.ScalarNode, Value: refVal}
+
+		} else if mapkey.Kind == yaml.ScalarNode && mapkey.Value == "Fn::GetAtt" {
+
+			config.Debugf("This is a GetAtt")
+			getAttVal, err := resolveGetAtt(mapval, resource)
+			if err != nil {
+				return nil, err
+			}
+
+			// Same as with Ref above, we need to replace the node
+
+			retval = &yaml.Node{Kind: yaml.ScalarNode, Value: getAttVal}
+
+		} else if mapkey.Kind == yaml.ScalarNode && mapkey.Value == "Fn::Sub" {
+
+			config.Debugf("This is a Sub")
+			subVal, err := resolveSub(mapval, resource)
+			if err != nil {
+				return nil, err
+			}
+
+			// Same as with Ref above, we need to replace the node
+
+			retval = &yaml.Node{Kind: yaml.ScalarNode, Value: subVal}
+
+		} else if mapval.Kind == yaml.MappingNode {
+			// Recurse on a child Mapping node
+			config.Debugf("Recursing on child Mapping node")
+			rn, err := resolveNode(mapval, resource)
+			if err != nil {
+				return nil, err
+			}
+			retval.Content[i+1] = rn
+		} else if mapval.Kind == yaml.SequenceNode {
+			// Recurse on each element in the sequence
+			for s, sNode := range mapval.Content {
+				if sNode.Kind == yaml.MappingNode {
+					rn, err := resolveNode(sNode, resource)
+					if err != nil {
+						return nil, err
+					}
+					retval.Content[i+1].Content[s] = rn
+				} // otherwise it's a Scalar and we leave it alone
+			}
+		}
+	}
+
+	return retval, nil
+}
+
+func resolveRef(refNode *yaml.Node, resource *Resource) (string, error) {
+	if refNode.Kind != yaml.ScalarNode {
+		return "", fmt.Errorf("ref Value is not a scalar for %v", resource.Name)
+	}
+
+	return resolveRefByName(refNode.Value, resource, make(map[string]string))
+}
+
+func resolvePseudoParam(name string) (string, error) {
+
+	p := strings.Replace(name, AWS_PREFIX, "", 1)
+	switch p {
+	case "AccountId":
+		return sts.GetAccountID()
+	case "Region":
+		return aws.Config().Region, nil
+	case "NotificationARNs":
+		// TODO: Can't return a string for this!
+		return "", errors.New("unsupported: AWS::NotificationARNs")
+	case "NoValue":
+		// TODO: Needs special handling to remove nodes from the template
+		return "", errors.New("unsupported: AWS::NoValue")
+	case "Partition":
+		region := aws.Config().Region
+		if strings.HasPrefix(region, "us-gov") {
+			return "aws-us-gov", nil
+		}
+		if strings.HasPrefix(region, "cn-") {
+			return "aws-cn", nil
+		}
+		if strings.HasPrefix(region, "us-iso-") {
+			return "aws-iso", nil
+		}
+		if strings.HasPrefix(region, "us-isob-") {
+			return "aws-iso-b", nil
+		}
+		return "aws", nil
+	case "StackId":
+		return "", errors.New("unsupported: AWS::StackId")
+	case "StackName":
+		return "", errors.New("unsupported: AWS::StackName")
+	case "URLSuffix":
+		return "", errors.New("unsupported: AWS::URLSuffix")
+	default:
+		return "", fmt.Errorf("unexpected AWS::%s", p)
+	}
+}
+
+func resolveRefByName(name string, resource *Resource, extra map[string]string) (string, error) {
+	config.Debugf("resolveRefByName %v", name)
+
+	// Handle pseudo-parameters
+	if strings.HasPrefix(name, AWS_PREFIX) {
+		return resolvePseudoParam(name)
+	}
+
+	// If extra has the name, use that. This comes from Fn::Sub
+	if val, ok := extra[name]; ok {
+		return val, nil
+	}
+
+	// Check to see if it is a Parameter
+	p, _ := deployedTemplate.GetParameter(name)
+	if p != nil {
+		config.Debugf("Found parameter %v", node.ToSJson(p))
+
+		// If the parameter value was supplied, use that value.
+		// DeployConfig already takes default values into account.
+
+		if pval, exists := templateConfig.GetParam(name); exists {
+			config.Debugf("Supplied param value: %v", pval)
+			return pval, nil
+		}
+	}
+
+	config.Debugf("Did not find %s in Parameters, checking Resources", name)
+
+	// Check to see if it is a reference to another resource
+	reffedResource, err := deployedTemplate.GetResource(name)
+	config.Debugf("reffedResource: %v", node.ToSJson(reffedResource))
+	if err == nil {
+		/*
+			// Get the Type of the reffed resource
+			_, t := s11n.GetMapValue(reffedResource, "Type")
+			if t == nil {
+				return "", fmt.Errorf("resource %s does not have a Type?", name)
+			}
+			reffedType := t.Value
+		*/
+
+		// Now we need to know, what does "Ref" mean for this resource type?
+		// For now assume it's always primaryIdentifier
+
+		// We don't need to query CCAPI to look at the schema for the type.
+		// Because we already set resource.Identifier in deployment.go
+
+		// Get a reference to the Resource we deployed from the global map
+		reffed, exists := resMap[name]
+		if !exists {
+			return "", fmt.Errorf("resource %s missing from global resource map", name)
+		}
+
+		// Look at the resource model returned from when we deployed that resource
+		config.Debugf("reffed id: %s,  model: %v", reffed.Identifier, reffed.Model)
+
+		return reffed.Identifier, nil
+
+	} else {
+		config.Debugf("%v", err)
+	}
+
+	// Error if we can't find it anywhere
+	return "", fmt.Errorf("cannot resolve %s", name)
+}
+
+// resolveGetAtt resolves a node with Fn::GetAtt
+func resolveGetAtt(n *yaml.Node, resource *Resource) (string, error) {
+	if n.Kind != yaml.SequenceNode {
+		return "", fmt.Errorf("getAtt Value is not a Sequence for %v", resource.Name)
+	}
+
+	name := n.Content[0].Value
+	attr := n.Content[1].Value
+
+	return resolveGetAttBy(name, attr, resource)
+}
+
+func resolveGetAttBy(name string, attr string, resource *Resource) (string, error) {
+
+	config.Debugf("GetAtt %v.%v", name, attr)
+
+	reffedResource, err := deployedTemplate.GetResource(name)
+	if err != nil {
+		return "", fmt.Errorf("can't find resource %s: %v", name, err)
+	}
+	config.Debugf("reffedResource: %v", node.ToSJson(reffedResource))
+
+	// Get a reference to the Resource we deployed from the global map
+	reffed, exists := resMap[name]
+	if !exists {
+		return "", fmt.Errorf("resource %s missing from global resource map", name)
+	}
+
+	// Look at the resource model returned from when we deployed that resource
+	config.Debugf("reffed id: %s,  model: %v", reffed.Identifier, reffed.Model)
+
+	// Parse the model to get the attribute
+	var j map[string]any
+	err = json.Unmarshal([]byte(reffed.Model), &j)
+	if err != nil {
+		return "", fmt.Errorf("unable to parse model: %v", err)
+	}
+
+	attrValue, exists := j[attr]
+	if !exists {
+		return "", fmt.Errorf("unable to find %s.%s in the deployed Model", name, attr)
+	}
+
+	return attrValue.(string), nil
+}
+
+func resolveSubWords(words []word, resource *Resource, extra map[string]string) (string, error) {
+
+	retval := ""
+	prefix := ""
+
+	for _, word := range words {
+		switch word.t {
+		case STR:
+			retval += word.w
+		case AWS:
+			prefix = AWS_PREFIX
+			fallthrough
+		case REF:
+			resolved, err := resolveRefByName(prefix+word.w, resource, extra)
+			if err != nil {
+				return "", err
+			}
+			retval += resolved
+		case GETATT:
+			left, right, found := strings.Cut(word.w, ".")
+			if !found {
+				return "", fmt.Errorf("unexpected GetAtt %s", word.w)
+			}
+			return resolveGetAttBy(left, right, resource)
+		default:
+			return "", fmt.Errorf("unexpected word type %v for %s", word.t, word.w)
+		}
+	}
+
+	return retval, nil
+}
+
+// resolveSub resolves a node with Fn::Sub
+func resolveSub(n *yaml.Node, resource *Resource) (string, error) {
+
+	// A Sub will either have a Scalar string,
+	// or a sequence of [String, Key:Val, Key:Val...]
+
+	if n.Kind == yaml.ScalarNode {
+
+		// ${X.Y} for GetAtt
+		// AWS:: variables
+		// Ref for single strings like ${MyParam} or ${MyBucket}
+		// Map values
+		// ${!Literal}
+		words, err := ParseSub(n.Value)
+		if err != nil {
+			return "", err
+		}
+
+		config.Debugf("Parsed \"%s\", got: %v", n.Value, words)
+
+		return resolveSubWords(words, resource, make(map[string]string))
+	} else if n.Kind == yaml.SequenceNode {
+		sub := n.Content[0].Value
+		if len(n.Content) != 2 {
+			return "", fmt.Errorf("expected Sub %s sequence to have two elements, got %v", sub, len(n.Content))
+		}
+		if n.Content[1].Kind != yaml.MappingNode {
+			return "", fmt.Errorf("expected Sub %s Content[1] to be a Mapping", sub)
+		}
+		words, err := ParseSub(sub)
+		if err != nil {
+			return "", err
+		}
+
+		config.Debugf("Parsed \"%s\", got: %v", sub, words)
+
+		// Inline map values can be provided in array elements 1..n
+		m := make(map[string]string)
+
+		mapping := n.Content[1]
+		for i := 0; i < len(mapping.Content); i += 2 {
+			key := mapping.Content[i].Value
+			subNode := mapping.Content[i+1]
+			var subVal string
+			if subNode.Kind == yaml.MappingNode {
+				// Resolve this node like normal
+				// Likely something like:
+				// [ "${A}", "A", Map [ "Ref", "B" ] ]
+				resolvedNode, err := resolveNode(subNode, resource)
+				config.Debugf("Sub sequence mapping resolved: %v", node.ToSJson(resolvedNode))
+				if resolvedNode.Kind != yaml.ScalarNode {
+					return "", fmt.Errorf("expected resolved %s: %s to be a Scalar", sub, key)
+				}
+				subVal = resolvedNode.Value
+				if err != nil {
+					return "", err
+				}
+			} else if subNode.Kind == yaml.ScalarNode {
+				subVal = subNode.Value
+			} else {
+				return "", fmt.Errorf("expected a Mapping or Scalar for Sub value %s: %s", sub, key)
+			}
+			m[key] = subVal
+		}
+
+		return resolveSubWords(words, resource, m)
+	} else {
+		return "", errors.New("expected a Scalar or a Sequence")
+	}
+}
diff --git a/internal/cmd/ccdeploy/resource.go b/internal/cmd/cc/resource.go
similarity index 78%
rename from internal/cmd/ccdeploy/resource.go
rename to internal/cmd/cc/resource.go
index be250deb..714a4e2b 100644
--- a/internal/cmd/ccdeploy/resource.go
+++ b/internal/cmd/cc/resource.go
@@ -1,7 +1,8 @@
-package ccdeploy
+package cc
 
 import (
 	"fmt"
+	"time"
 
 	"github.com/aws-cloudformation/rain/cft/diff"
 	"gopkg.in/yaml.v3"
@@ -17,6 +18,8 @@ const (
 	Canceled
 )
 
+var stateIcons map[ResourceState]string
+
 type Resource struct {
 	Name       string
 	Type       string
@@ -27,7 +30,8 @@ type Resource struct {
 	Model      string
 	Action     diff.ActionType
 	PriorJson  string
-	// TODO - Add elapsed time
+	Start      time.Time
+	End        time.Time
 }
 
 func (r Resource) String() string {
@@ -59,3 +63,12 @@ func NewResource(name string,
 	resMap[name] = r // TODO - This is global, do we really need it?
 	return r
 }
+
+func init() {
+	stateIcons = make(map[ResourceState]string)
+	stateIcons[Waiting] = "⏳"
+	stateIcons[Deploying] = "⏩"
+	stateIcons[Failed] = "❌"
+	stateIcons[Canceled] = "🚫"
+	stateIcons[Deployed] = "✅"
+}
diff --git a/internal/cmd/ccrm/ccrm.go b/internal/cmd/cc/rm.go
similarity index 74%
rename from internal/cmd/ccrm/ccrm.go
rename to internal/cmd/cc/rm.go
index 04aef290..f1cc5748 100644
--- a/internal/cmd/ccrm/ccrm.go
+++ b/internal/cmd/cc/rm.go
@@ -1,4 +1,4 @@
-package ccrm
+package cc
 
 import (
 	"fmt"
@@ -7,7 +7,6 @@ import (
 	"github.com/aws-cloudformation/rain/cft/format"
 	"github.com/aws-cloudformation/rain/cft/parse"
 	"github.com/aws-cloudformation/rain/internal/aws/s3"
-	"github.com/aws-cloudformation/rain/internal/cmd/ccdeploy"
 	"github.com/aws-cloudformation/rain/internal/config"
 	"github.com/aws-cloudformation/rain/internal/console"
 	"github.com/aws-cloudformation/rain/internal/console/spinner"
@@ -17,24 +16,23 @@ import (
 	"gopkg.in/yaml.v3"
 )
 
-var yes bool
-var detach bool
-var roleArn string
-var Experimental bool
-
 // Cmd is the rm command's entrypoint
-var Cmd = &cobra.Command{
-	Use:                   "ccrm <name>",
-	Short:                 "Delete a deployment created by ccdeploy (Experimental!)",
-	Long:                  "Deletes the resources in the ccdeploy deployment named <name> and waits for all CloudControl API calls to complete. This is an experimental feature that requires the -x flag to run.",
+var CCRmCmd = &cobra.Command{
+	Use:                   "rm <name>",
+	Short:                 "Delete a deployment created by cc deploy (Experimental!)",
+	Long:                  "Deletes the resources in the cc deploy deployment named <name> and waits for all CloudControl API calls to complete. This is an experimental feature that requires the -x flag to run.",
 	Args:                  cobra.ExactArgs(1),
 	Aliases:               []string{"ccremove", "ccdel", "ccdelete"},
 	DisableFlagsInUseLine: true,
 	Run: func(cmd *cobra.Command, args []string) {
 		name := args[0]
 
+		if !Experimental {
+			panic("Please add the --experimental arg to use this feature")
+		}
+
 		spinner.Push("Fetching deployment status")
-		key := fmt.Sprintf("%v/%v.yaml", ccdeploy.STATE_DIR, name) // deployments/name
+		key := fmt.Sprintf("%v/%v.yaml", STATE_DIR, name) // deployments/name
 		var state cft.Template
 
 		// Call RainBucket for side-effects in case we want to force bucket creation
@@ -113,16 +111,14 @@ var Cmd = &cobra.Command{
 
 		config.Debugf("About to delete deployment: %v", format.CftToYaml(template))
 
-		results, err := ccdeploy.DeployTemplate(template)
+		results, err := DeployTemplate(template)
 		if err != nil {
 			panic(err)
 		}
 
 		spinner.StopTimer()
 
-		for _, resource := range results.Resources {
-			fmt.Printf("Removed %v\n", resource)
-		}
+		results.Summarize()
 		fmt.Printf("Deployment %v successfully removed\n", name)
 
 		spinner.Push("Deleting state file")
@@ -136,8 +132,7 @@ var Cmd = &cobra.Command{
 }
 
 func init() {
-	Cmd.Flags().BoolVarP(&detach, "detach", "d", false, "once removal has started, don't wait around for it to finish")
-	Cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask questions; just delete")
-	Cmd.Flags().StringVar(&roleArn, "role-arn", "", "ARN of an IAM role that CloudFormation should assume to remove the stack")
-	Cmd.Flags().BoolVarP(&Experimental, "experimental", "x", false, "Acknowledge that this is an experimental feature")
+	CCRmCmd.Flags().BoolVar(&config.Debug, "debug", false, "Output debugging information")
+	CCRmCmd.Flags().BoolVarP(&Experimental, "experimental", "x", false, "Acknowledge that this is an experimental feature")
+	CCRmCmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask questions; just delete")
 }
diff --git a/internal/cmd/ccdeploy/state.go b/internal/cmd/cc/state.go
similarity index 57%
rename from internal/cmd/ccdeploy/state.go
rename to internal/cmd/cc/state.go
index 6ad7c69d..ffcdc3d3 100644
--- a/internal/cmd/ccdeploy/state.go
+++ b/internal/cmd/cc/state.go
@@ -1,4 +1,4 @@
-package ccdeploy
+package cc
 
 import (
 	"encoding/json"
@@ -16,6 +16,7 @@ import (
 	"github.com/aws-cloudformation/rain/internal/s11n"
 	"github.com/aws/aws-sdk-go-v2/service/s3/types"
 	"github.com/google/uuid"
+	"github.com/spf13/cobra"
 	"gopkg.in/yaml.v3"
 )
 
@@ -41,6 +42,13 @@ func addCommon(stateMap *yaml.Node, absPath string) {
 	}
 }
 
+// deleteState removes the state file.
+// This is necessary when the user cancels a fresh deployment
+func deleteState(name string, bucketName string) error {
+	key := fmt.Sprintf("%v/%v.yaml", STATE_DIR, name) // deployments/name
+	return s3.DeleteObject(bucketName, key)
+}
+
 // checkState looks for an existing state file.
 //
 // If one does not exist, it is created.
@@ -56,7 +64,8 @@ func checkState(
 	template cft.Template,
 	bucketName string,
 	priorLock string,
-	absPath string) (*StateResult, error) {
+	absPath string,
+	unlockId string) (*StateResult, error) {
 
 	key := fmt.Sprintf("%v/%v.yaml", STATE_DIR, name) // deployments/name
 	var state cft.Template
@@ -129,14 +138,29 @@ func checkState(
 		}
 		result.Lock = lock
 
+		ignoreLock := false
 		if lock != "" {
-			return nil, fmt.Errorf("lock: %v", lock)
+			if unlockId != "" {
+				if lock != unlockId {
+					return nil, fmt.Errorf("unlock %v does not match found lock %v", unlockId, lock)
+				} else {
+					fmt.Println("Unlocking the locked state file")
+					ignoreLock = true
+				}
+			} else {
+				msg := fmt.Sprintf("Found a locked state file (lock: %v). This means another process is currently deploying this template, or a deployment failed to complete. You will need to manually resolve the issue, or you can try to resume the deployment by running cc deploy with --unlock <lock>", lock)
+				return nil, errors.New(msg)
+			}
 		}
 
 		// We are safe to proceed with an update.
 		// Write a new lock back to the state file stored in S3.
-		lock = uuid.New().String()
-		node.Add(stateMap, "Lock", lock)
+		// If we're unlocking to continue a failed deployment, leave it
+		// since we would need to re-lock it here anyway
+		if !ignoreLock {
+			lock = uuid.New().String()
+			node.Add(stateMap, "Lock", lock)
+		}
 
 		// Add common elements
 		addCommon(stateMap, absPath)
@@ -165,48 +189,50 @@ func writeState(
 	original := format.String(state, format.Options{JSON: false, Unsorted: false})
 	config.Debugf("writeState original template: %v", original)
 
-	stateMap := cft.AppendStateMap(state)
-	node.Add(stateMap, "LastWriteTime", time.Now().Format(time.RFC3339))
-	addCommon(stateMap, absPath)
-	resourceModels := node.AddMap(stateMap, "ResourceModels")
+	if results != nil {
+		stateMap := cft.AppendStateMap(state)
+		node.Add(stateMap, "LastWriteTime", time.Now().Format(time.RFC3339))
+		addCommon(stateMap, absPath)
+		resourceModels := node.AddMap(stateMap, "ResourceModels")
 
-	// Iterate over each resource in the results.
-	// Add a State section to the state resource and write the resource model
+		// Iterate over each resource in the results.
+		// Add a State section to the state resource and write the resource model
 
-	rootMap := state.Node.Content[0]
-	_, resourceMap := s11n.GetMapValue(rootMap, "Resources")
-	if resourceMap == nil {
-		panic("Expected to find a Resources section in the template")
-	}
-
-	for name, resource := range results.Resources {
-		if resource.Action == diff.Delete {
-			config.Debugf("Resource %v was deleted, not writing to state", name)
-			continue
+		rootMap := state.Node.Content[0]
+		_, resourceMap := s11n.GetMapValue(rootMap, "Resources")
+		if resourceMap == nil {
+			panic("Expected to find a Resources section in the template")
 		}
-		config.Debugf("Writing %v to state file", name)
-		var stateResource *yaml.Node
-		for i, r := range resourceMap.Content {
-			if r.Value == name {
-				stateResource = resourceMap.Content[i+1]
-				break
+
+		for name, resource := range results.Resources {
+			if resource.Action == diff.Delete {
+				config.Debugf("Resource %v was deleted, not writing to state", name)
+				continue
+			}
+			config.Debugf("Writing %v to state file", name)
+			var stateResource *yaml.Node
+			for i, r := range resourceMap.Content {
+				if r.Value == name {
+					stateResource = resourceMap.Content[i+1]
+					break
+				}
+			}
+			if stateResource == nil {
+				return fmt.Errorf("did not find %v in the state template", name)
 			}
-		}
-		if stateResource == nil {
-			return fmt.Errorf("did not find %v in the state template", name)
-		}
 
-		resourceStateMap := node.AddMap(resourceModels, name)
-		node.Add(resourceStateMap, "Identifier", resource.Identifier)
-		modelMap := node.AddMap(resourceStateMap, "Model")
-		var parsed map[string]any
-		json.Unmarshal([]byte(resource.Model), &parsed)
-		var n yaml.Node
-		err := n.Encode(parsed)
-		if err != nil {
-			return err
+			resourceStateMap := node.AddMap(resourceModels, name)
+			node.Add(resourceStateMap, "Identifier", resource.Identifier)
+			modelMap := node.AddMap(resourceStateMap, "Model")
+			var parsed map[string]any
+			json.Unmarshal([]byte(resource.Model), &parsed)
+			var n yaml.Node
+			err := n.Encode(parsed)
+			if err != nil {
+				return err
+			}
+			modelMap.Content = append(modelMap.Content, n.Content...)
 		}
-		modelMap.Content = append(modelMap.Content, n.Content...)
 	}
 
 	str := format.String(state, format.Options{JSON: false, Unsorted: false})
@@ -219,3 +245,40 @@ func writeState(
 
 	return nil
 }
+
+// run is the cobra command for rain cc state
+func runState(cmd *cobra.Command, args []string) {
+	name := args[0]
+
+	if !Experimental {
+		panic("Please add the --experimental arg to use this feature")
+	}
+
+	// Call RainBucket for side-effects in case we want to force bucket creation
+	bucketName := s3.RainBucket(true)
+
+	key := fmt.Sprintf("%v/%v.yaml", STATE_DIR, name) // deployments/name
+
+	obj, err := s3.GetObject(bucketName, key)
+	if err != nil {
+		fmt.Printf("Unable to download state: %v", err)
+		return
+	}
+
+	fmt.Println(string(obj))
+}
+
+var CCStateCmd = &cobra.Command{
+	Use:   "state <name>",
+	Short: "Download the state file for a template deployed with cc deploy",
+	Long: `When deploying templates with the cc command, a state file is created and stored in the rain assets bucket. This command outputs the contents of that file.
+`,
+	Args:                  cobra.ExactArgs(1),
+	DisableFlagsInUseLine: true,
+	Run:                   runState,
+}
+
+func init() {
+	CCStateCmd.Flags().BoolVar(&config.Debug, "debug", false, "Output debugging information")
+	CCStateCmd.Flags().BoolVarP(&Experimental, "experimental", "x", false, "Acknowledge that this is an experimental feature")
+}
diff --git a/internal/cmd/ccdeploy/update.go b/internal/cmd/cc/update.go
similarity index 84%
rename from internal/cmd/ccdeploy/update.go
rename to internal/cmd/cc/update.go
index dc6056b0..b0b27836 100644
--- a/internal/cmd/ccdeploy/update.go
+++ b/internal/cmd/cc/update.go
@@ -1,4 +1,4 @@
-package ccdeploy
+package cc
 
 import (
 	"fmt"
@@ -10,6 +10,8 @@ import (
 	"github.com/aws-cloudformation/rain/internal/config"
 	"github.com/aws-cloudformation/rain/internal/node"
 	"github.com/aws-cloudformation/rain/internal/s11n"
+	"github.com/aws-cloudformation/rain/internal/table"
+	"github.com/fatih/color"
 	"gopkg.in/yaml.v3"
 )
 
@@ -33,7 +35,7 @@ func update(stateTemplate cft.Template, template cft.Template) (cft.Template, er
 			 State:
 				Action: Create or Update or Delete or None
 				Identifier: ...
-				ResourceModel: (Might need this for drift detection)
+				ResourceModel: ...
 				PriorJson: (We need this for ccapi update)
 
 	*/
@@ -55,6 +57,7 @@ func update(stateTemplate cft.Template, template cft.Template) (cft.Template, er
 	if stateResourceModels == nil {
 		panic("Expected to find State.ResourceModels in the state template")
 	}
+
 	identifiers := make(map[string]string, 0)
 	models := make(map[string]*yaml.Node, 0)
 	for i, v := range stateResourceModels.Content {
@@ -123,16 +126,24 @@ func update(stateTemplate cft.Template, template cft.Template) (cft.Template, er
 		} else {
 			// Create, Update, None
 			node.Add(rmap, "Action", string(v))
+
 			// Add the identifier so we know what to update
 			if identifier, ok := identifiers[k]; ok {
 				node.Add(rmap, "Identifier", identifier)
+				// TODO: When would we not have the identifier?
+				// We need it below when there is no model and we have to query for it
 			}
+
 			// Add the resource model that represents the current actual state of
 			// the resource based on the ccapi GetResource call
 			if model, ok := models[k]; ok {
+				// TODO: We always need this.
+				// Do we query for it here if it's missing? Or during deployment?
+
 				modelMap := node.AddMap(rmap, "ResourceModel")
 				modelMap.Content = model.Content
 			}
+
 			// Add PriorJson to represent the prior properties set by the user
 			if v == diff.Update {
 				priorProps := ccapi.ToJsonProps(stateResources[k])
@@ -190,8 +201,10 @@ func update(stateTemplate cft.Template, template cft.Template) (cft.Template, er
 				New template desired state:
 				    DelaySeconds: 2
 
+				(If any resources resulted in the above message)
+
 				What would you like to do?
-				1) Stop the deployment.
+				1) Do not deploy anything
 				2) Deploy anyway, applying my latest template as the source of truth
 				3) Deploy anyway, applying all of my changes except the drifted properties
 				??? Any other choices? Does 3 make sense?
@@ -204,6 +217,8 @@ func update(stateTemplate cft.Template, template cft.Template) (cft.Template, er
 			This makes our diff generation more complicated, since there are two
 			different diffs to consider.
 
+			A table view might be better, with a colorized 3-way diff.
+
 
 	*/
 
@@ -226,34 +241,60 @@ func summarizeChanges(changes cft.Template) {
 	if resourceMap == nil {
 		panic("expected Resources")
 	}
-	fmt.Println("Summary of deployment changes:")
+
+	fmt.Println("Review the resources that are about to be deployed")
+	fmt.Println()
+
+	tbl := table.New("Action", "Type", "LogicalId", "Identifier")
+	headerFmt := color.New(color.FgBlue, color.Underline).SprintfFunc()
+	tbl.WithHeaderFormatter(headerFmt)
+
 	for i, v := range resourceMap.Content {
 		if i%2 == 0 {
 			var action string
 			var t string
 			var ident string
 			name := v.Value
-			_, stateMap := s11n.GetMapValue(resourceMap.Content[i+1], "State")
-			if stateMap == nil {
-				panic(fmt.Sprintf("expected State on resource %v", name))
-			}
+
+			// Get the Type
 			_, typeNode := s11n.GetMapValue(resourceMap.Content[i+1], "Type")
 			if typeNode == nil {
 				panic(fmt.Sprintf("expected Type on resource %v", name))
 			}
 			t = typeNode.Value
-			for si, sv := range stateMap.Content {
-				if si%2 == 0 {
-					val := stateMap.Content[si+1].Value
-					if sv.Value == "Action" {
-						action = val
-					} else if sv.Value == "Identifier" {
-						ident = val
+
+			// Get the action and identifier
+			_, stateMap := s11n.GetMapValue(resourceMap.Content[i+1], "State")
+			if stateMap == nil {
+				action = "Create"
+				ident = ""
+			} else {
+				for si, sv := range stateMap.Content {
+					if si%2 == 0 {
+						val := stateMap.Content[si+1].Value
+						if sv.Value == "Action" {
+							action = val
+						} else if sv.Value == "Identifier" {
+							ident = val
+						}
 					}
 				}
 			}
-			fmt.Printf("%v\t%v\t%v\t%v\n", name, t, action, ident)
+			//fmt.Printf("%v\t%v\t%v\t%v\n", name, t, action, ident)
+			var formatter table.Formatter
+			switch action {
+			case "Create":
+				formatter = createFormat
+			case "Update":
+				formatter = updateFormat
+			case "Delete":
+				formatter = deleteFormat
+			default:
+				formatter = nil
+			}
+			tbl.AddRowf(formatter, action, t, name, ident)
 		}
 	}
-
+	tbl.Print()
+	fmt.Println()
 }
diff --git a/internal/cmd/ccdeploy/ccdeploy.go b/internal/cmd/ccdeploy/ccdeploy.go
deleted file mode 100644
index 4a384ea4..00000000
--- a/internal/cmd/ccdeploy/ccdeploy.go
+++ /dev/null
@@ -1,138 +0,0 @@
-package ccdeploy
-
-import (
-	"errors"
-	"fmt"
-	"path/filepath"
-
-	"github.com/aws-cloudformation/rain/cft"
-	"github.com/aws-cloudformation/rain/cft/format"
-	"github.com/aws-cloudformation/rain/cft/pkg"
-	"github.com/aws-cloudformation/rain/internal/aws/s3"
-	"github.com/aws-cloudformation/rain/internal/config"
-	"github.com/aws-cloudformation/rain/internal/console"
-	"github.com/aws-cloudformation/rain/internal/console/spinner"
-	"github.com/aws-cloudformation/rain/internal/ui"
-	"github.com/spf13/cobra"
-)
-
-var params []string
-var tags []string
-var configFilePath string
-var Experimental bool
-
-// var template cft.Template
-var resMap map[string]*Resource // TODO - This is kinda bad (global)
-
-// PackageTemplate reads the template and performs any necessary packaging on it
-// before deployment. The rain bucket will be created if it does not already exist.
-func PackageTemplate(fn string, yes bool) cft.Template {
-
-	t, err := pkg.File(fn)
-	if err != nil {
-		panic(ui.Errorf(err, "error packaging template '%s'", fn))
-	}
-
-	return t
-}
-
-func run(cmd *cobra.Command, args []string) {
-	fn := args[0]
-	name := args[1]
-	base := filepath.Base(fn)
-	absPath, _ := filepath.Abs(fn)
-
-	// Call RainBucket for side-effects in case we want to force bucket creation
-	bucketName := s3.RainBucket(true)
-
-	// Package template
-	spinner.Push(fmt.Sprintf("Preparing template '%s'", base))
-	template := PackageTemplate(fn, true)
-	spinner.Pop()
-
-	// TODO - Get DeployConfig (modified to remove stack references...)
-
-	// Compare against the current state to see what has changed, if this
-	// is an update
-	spinner.Push("Checking state")
-	stateResult, stateError := checkState(name, template, bucketName, "", absPath)
-	if stateError != nil {
-		msg := fmt.Sprintf("Found a locked state file (%v). This means another process is currently deploying this template, or a deployment failed to complete. You will need to manually resolve the issue, or you can try to resume the deployment by running ccdeploy with --continue <lock>", stateError)
-		panic(msg)
-	}
-	spinner.Pop()
-
-	config.Debugf("StateFile:\n%v", format.String(stateResult.StateFile,
-		format.Options{JSON: false, Unsorted: false}))
-
-	var changes cft.Template
-
-	if stateResult.IsUpdate {
-		var err error
-		changes, err = update(stateResult.StateFile, template)
-		if err != nil {
-			panic(err)
-		}
-		summarizeChanges(changes)
-
-		if !console.Confirm(true, "Do you wish to continue?") {
-			panic(errors.New("user cancelled deployment"))
-		}
-
-	} else {
-		// Deploy the provided template for the first time
-		changes = template
-	}
-
-	// TODO - Resolve intrinsics (yikes!)
-
-	spinner.StartTimer(fmt.Sprintf("Deploying %v", name))
-	results, err := DeployTemplate(changes)
-	if err != nil {
-		// An unexpected error that prevented deployment from starting
-		panic(err)
-	}
-	spinner.StopTimer()
-
-	if !results.Succeeded {
-		fmt.Println("Deployment failed.")
-
-		// Leave the state file locked. Needs to be resolved manually.
-	} else {
-		fmt.Println("Deployment completed successfully!")
-
-		// Unlock the state file and record current values
-		err := writeState(template, results, bucketName, name, absPath)
-		if err != nil {
-			panic(fmt.Errorf("unable to write state file: %v", err))
-		}
-	}
-
-	for _, resource := range results.Resources {
-		fmt.Printf("%v\n", resource)
-	}
-
-}
-
-var Cmd = &cobra.Command{
-	Use:   "ccdeploy <template> <name>",
-	Short: "Deploy a local template directly using the Cloud Control API (Experimental!)",
-	Long: `Creates or updates resources directly using Cloud Control API from the template file <template>.
-You must pass the --experimental (-x) flag to use this command, to acknowledge that it is experimental and likely to be unstable!
-`,
-	Args:                  cobra.ExactArgs(2),
-	DisableFlagsInUseLine: true,
-	Run:                   run,
-}
-
-func init() {
-
-	Cmd.Flags().BoolVar(&config.Debug, "debug", false, "Output debugging information")
-	Cmd.Flags().StringSliceVar(&tags, "tags", []string{}, "add tags to the stack; use the format key1=value1,key2=value2")
-	Cmd.Flags().StringSliceVar(&params, "params", []string{}, "set parameter values; use the format key1=value1,key2=value2")
-	Cmd.Flags().StringVarP(&configFilePath, "config", "c", "", "YAML or JSON file to set tags and parameters")
-	Cmd.Flags().BoolVarP(&Experimental, "experimental", "x", false, "Acknowledge that this is an experimental feature")
-
-	resMap = make(map[string]*Resource)
-
-}
diff --git a/internal/cmd/ccdeploy/resolve.go b/internal/cmd/ccdeploy/resolve.go
deleted file mode 100644
index 37d9e319..00000000
--- a/internal/cmd/ccdeploy/resolve.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package ccdeploy
-
-import (
-	"github.com/aws-cloudformation/rain/internal/node"
-	"gopkg.in/yaml.v3"
-)
-
-// resolve resolves CloudFormation intrinsic functions
-//
-// Supported:
-//
-// Not Supported:
-//
-//	Fn::Base64
-//	Fn::Cidr
-//	Condition functions
-//	Fn::FindInMap
-//	Fn::ForEach
-//	Fn::GetAtt
-//	Fn::GetAZs
-//	Fn::ImportValue
-//	Fn::Join
-//	Fn::Length
-//	Fn::Select
-//	Fn::Split
-//	Fn::Sub
-//	Fn::ToJsonString
-//	Fn::Transform
-//	Ref
-func resolve(resource *Resource) (*yaml.Node, error) {
-	return node.Clone(resource.Node), nil
-
-	// TODO
-}
diff --git a/internal/cmd/forecast/estimate.go b/internal/cmd/forecast/estimate.go
index a8077fb4..6cf3c34d 100644
--- a/internal/cmd/forecast/estimate.go
+++ b/internal/cmd/forecast/estimate.go
@@ -1198,7 +1198,7 @@ func InitEstimates() {
 	Estimates["AWS::SNS::Topic"] = NewResourceEstimate("AWS::SNS::Topic", 4, 7, 1)
 	Estimates["AWS::SNS::TopicInlinePolicy"] = NewResourceEstimate("AWS::SNS::TopicInlinePolicy", 32, 32, 1)
 	Estimates["AWS::SNS::TopicPolicy"] = NewResourceEstimate("AWS::SNS::TopicPolicy", 1, 1, 1)
-	Estimates["AWS::SQS::Queue"] = NewResourceEstimate("AWS::SQS::Queue", 72, 2, 1)
+	Estimates["AWS::SQS::Queue"] = NewResourceEstimate("AWS::SQS::Queue", 72, 2, 60)
 	Estimates["AWS::SQS::QueueInlinePolicy"] = NewResourceEstimate("AWS::SQS::QueueInlinePolicy", 32, 1, 1)
 	Estimates["AWS::SQS::QueuePolicy"] = NewResourceEstimate("AWS::SQS::QueuePolicy", 1, 0, 1)
 	Estimates["AWS::SSM::Association"] = NewResourceEstimate("AWS::SSM::Association", 2, 0, 1)
diff --git a/internal/cmd/rain/rain.go b/internal/cmd/rain/rain.go
index 57c66a2a..f922b0ce 100644
--- a/internal/cmd/rain/rain.go
+++ b/internal/cmd/rain/rain.go
@@ -11,8 +11,7 @@ import (
 	"github.com/aws-cloudformation/rain/internal/cmd/bootstrap"
 	"github.com/aws-cloudformation/rain/internal/cmd/build"
 	"github.com/aws-cloudformation/rain/internal/cmd/cat"
-	"github.com/aws-cloudformation/rain/internal/cmd/ccdeploy"
-	"github.com/aws-cloudformation/rain/internal/cmd/ccrm"
+	"github.com/aws-cloudformation/rain/internal/cmd/cc"
 	consolecmd "github.com/aws-cloudformation/rain/internal/cmd/console"
 	"github.com/aws-cloudformation/rain/internal/cmd/deploy"
 	"github.com/aws-cloudformation/rain/internal/cmd/diff"
@@ -85,8 +84,7 @@ func init() {
 	// Stack commands
 	addCommand(stackGroup, true, false, cat.Cmd)
 	addCommand(stackGroup, true, true, deploy.Cmd)
-	addCommand(stackGroup, true, true, ccdeploy.Cmd)
-	addCommand(stackGroup, true, true, ccrm.Cmd)
+	addCommand(stackGroup, true, true, cc.Cmd)
 	addCommand(stackGroup, true, false, logs.Cmd)
 	addCommand(stackGroup, true, false, ls.Cmd)
 	addCommand(stackGroup, true, false, rm.Cmd)
diff --git a/internal/dc/dc.go b/internal/dc/dc.go
index 42e6a726..7f6b9b7c 100644
--- a/internal/dc/dc.go
+++ b/internal/dc/dc.go
@@ -32,6 +32,19 @@ type DeployConfig struct {
 	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(
@@ -183,24 +196,6 @@ func GetStackName(stackName string, base string) string {
 	return stackName
 }
 
-// ConfigFromStack returns a yaml string containing the tags and parameters of the given stack
-func ConfigFromStack(stack types.Stack) (string, error) {
-	configFile := &configFileFormat{
-		Parameters: make(map[string]string),
-		Tags:       make(map[string]string),
-	}
-
-	for _, tag := range stack.Tags {
-		configFile.Tags[*tag.Key] = *tag.Value
-	}
-	for _, parameter := range stack.Parameters {
-		configFile.Parameters[*parameter.ParameterKey] = *parameter.ParameterValue
-	}
-
-	configFileContent, err := yaml.Marshal(configFile)
-	return string(configFileContent), err
-}
-
 // GetDeployConfig populates an instance of DeployConfig based on user-supplied values
 func GetDeployConfig(
 	tags []string,
diff --git a/internal/table/table.go b/internal/table/table.go
new file mode 100644
index 00000000..6e60b54b
--- /dev/null
+++ b/internal/table/table.go
@@ -0,0 +1,328 @@
+// Package table provides a convenient way to generate tabular output of any
+// data, primarily useful for CLI tools.
+//
+// ==============================================================
+// Forked from https://github.com/rodaine/table
+// I liked this package but needed to make some breaking changes
+// ==============================================================
+//
+// Columns are left-aligned and padded to accomodate the largest cell in that
+// column.
+//
+// Source: https://github.com/rodaine/table
+//
+//	table.DefaultHeaderFormatter = func(format string, vals ...interface{}) string {
+//	  return strings.ToUpper(fmt.Sprintf(format, vals...))
+//	}
+//
+//	tbl := table.New("ID", "Name", "Cost ($)")
+//
+//	for _, widget := range Widgets {
+//	  tbl.AddRow(widget.ID, widget.Name, widget.Cost)
+//	}
+//
+//	tbl.Print()
+//
+//	// Output:
+//	// ID  NAME      COST ($)
+//	// 1   Foobar    1.23
+//	// 2   Fizzbuzz  4.56
+//	// 3   Gizmo     78.90
+package table
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"strings"
+	"unicode/utf8"
+)
+
+// These are the default properties for all Tables created from this package
+// and can be modified.
+var (
+	// DefaultPadding specifies the number of spaces between columns in a table.
+	DefaultPadding = 2
+
+	// DefaultWriter specifies the output io.Writer for the Table.Print method.
+	DefaultWriter io.Writer = os.Stdout
+
+	// DefaultHeaderFormatter specifies the default Formatter for the table header.
+	DefaultHeaderFormatter Formatter
+
+	// DefaultFirstColumnFormatter specifies the default Formatter for the first column cells.
+	DefaultFirstColumnFormatter Formatter
+
+	// DefaultWidthFunc specifies the default WidthFunc for calculating column widths
+	DefaultWidthFunc WidthFunc = utf8.RuneCountInString
+)
+
+// Formatter functions expose a fmt.Sprintf signature that can be used to modify
+// the display of the text in either the header or first column of a Table.
+// The formatter should not change the width of original text as printed since
+// column widths are calculated pre-formatting (though this issue can be mitigated
+// with increased padding).
+//
+//	tbl.WithHeaderFormatter(func(format string, vals ...interface{}) string {
+//	  return strings.ToUpper(fmt.Sprintf(format, vals...))
+//	})
+//
+// A good use case for formatters is to use ANSI escape codes to color the cells
+// for a nicer interface. The package color (https://github.com/fatih/color) makes
+// it easy to generate these automatically: http://godoc.org/github.com/fatih/color#Color.SprintfFunc
+type Formatter func(string, ...interface{}) string
+
+// A WidthFunc calculates the width of a string. By default, the number of runes
+// is used but this may not be appropriate for certain character sets. The
+// package runewidth (https://github.com/mattn/go-runewidth) could be used to
+// accomodate multi-cell characters (such as emoji or CJK characters).
+type WidthFunc func(string) int
+
+type row struct {
+	s []string
+	f Formatter
+}
+
+// Table describes the interface for building up a tabular representation of data.
+// It exposes fluent/chainable methods for convenient table building.
+//
+// WithHeaderFormatter and WithFirstColumnFormatter sets the Formatter for the
+// header and first column, respectively. If nil is passed in (the default), no
+// formatting will be applied.
+//
+//	New("foo", "bar").WithFirstColumnFormatter(func(f string, v ...interface{}) string {
+//	  return strings.ToUpper(fmt.Sprintf(f, v...))
+//	})
+//
+// WithPadding specifies the minimum padding between cells in a row and defaults
+// to DefaultPadding. Padding values less than or equal to zero apply no extra
+// padding between the columns.
+//
+//	New("foo", "bar").WithPadding(3)
+//
+// WithWriter modifies the writer which Print outputs to, defaulting to DefaultWriter
+// when instantiated. If nil is passed, os.Stdout will be used.
+//
+//	New("foo", "bar").WithWriter(os.Stderr)
+//
+// WithWidthFunc sets the function used to calculate the width of the string in
+// a column. By default, the number of utf8 runes in the string is used.
+//
+// AddRow adds another row of data to the table. Any values can be passed in and
+// will be output as its string representation as described in the fmt standard
+// package. Rows can have less cells than the total number of columns in the table;
+// subsequent cells will be rendered empty. Rows with more cells than the total
+// number of columns will be truncated. References to the data are not held, so
+// the passed in values can be modified without affecting the table's output.
+//
+//	New("foo", "bar").AddRow("fizz", "buzz").AddRow(time.Now()).AddRow(1, 2, 3).Print()
+//	// Output:
+//	// foo                              bar
+//	// fizz                             buzz
+//	// 2006-01-02 15:04:05.0 -0700 MST
+//	// 1                                2
+//
+// Print writes the string representation of the table to the provided writer.
+// Print can be called multiple times, even after subsequent mutations of the
+// provided data. The output is always preceded and followed by a new line.
+type Table interface {
+	WithHeaderFormatter(f Formatter) Table
+	WithFirstColumnFormatter(f Formatter) Table
+	WithPadding(p int) Table
+	WithWriter(w io.Writer) Table
+	WithWidthFunc(f WidthFunc) Table
+
+	AddRow(vals ...interface{}) Table
+	AddRowf(f Formatter, vals ...interface{}) Table
+	SetRows(rows [][]string) Table
+	Print()
+}
+
+// New creates a Table instance with the specified header(s) provided. The number
+// of columns is fixed at this point to len(columnHeaders) and the defined defaults
+// are set on the instance.
+func New(columnHeaders ...interface{}) Table {
+	t := table{header: make([]string, len(columnHeaders))}
+
+	t.WithPadding(DefaultPadding)
+	t.WithWriter(DefaultWriter)
+	t.WithHeaderFormatter(DefaultHeaderFormatter)
+	t.WithFirstColumnFormatter(DefaultFirstColumnFormatter)
+	t.WithWidthFunc(DefaultWidthFunc)
+
+	for i, col := range columnHeaders {
+		t.header[i] = fmt.Sprint(col)
+	}
+
+	return &t
+}
+
+type table struct {
+	FirstColumnFormatter Formatter
+	HeaderFormatter      Formatter
+	Padding              int
+	Writer               io.Writer
+	Width                WidthFunc
+
+	header []string
+	rows   []row
+	widths []int
+}
+
+func (t *table) WithHeaderFormatter(f Formatter) Table {
+	t.HeaderFormatter = f
+	return t
+}
+
+func (t *table) WithFirstColumnFormatter(f Formatter) Table {
+	t.FirstColumnFormatter = f
+	return t
+}
+
+func (t *table) WithPadding(p int) Table {
+	if p < 0 {
+		p = 0
+	}
+
+	t.Padding = p
+	return t
+}
+
+func (t *table) WithWriter(w io.Writer) Table {
+	if w == nil {
+		w = os.Stdout
+	}
+
+	t.Writer = w
+	return t
+}
+
+func (t *table) WithWidthFunc(f WidthFunc) Table {
+	t.Width = f
+	return t
+}
+
+func (t *table) AddRow(vals ...interface{}) Table {
+	var f Formatter
+	return t.AddRowf(f, vals...)
+}
+
+func (t *table) AddRowf(f Formatter, vals ...interface{}) Table {
+
+	maxNumNewlines := 0
+	for _, val := range vals {
+		maxNumNewlines = max(strings.Count(fmt.Sprint(val), "\n"), maxNumNewlines)
+	}
+	for i := 0; i <= maxNumNewlines; i++ {
+		r := make([]string, len(t.header))
+		for j, val := range vals {
+			if j >= len(t.header) {
+				break
+			}
+			v := strings.Split(fmt.Sprint(val), "\n")
+			r[j] = safeOffset(v, i)
+		}
+		t.rows = append(t.rows, row{s: r, f: f})
+	}
+
+	return t
+}
+
+func (t *table) SetRows(rows [][]string) Table {
+	t.rows = []row{}
+	headerLength := len(t.header)
+
+	for _, r := range rows {
+		if len(r) > headerLength {
+			t.rows = append(t.rows, row{s: r[:headerLength], f: nil})
+		} else {
+			t.rows = append(t.rows, row{s: r, f: nil})
+		}
+	}
+
+	return t
+}
+
+func (t *table) Print() {
+	format := strings.Repeat("%s", len(t.header)) + "\n"
+	t.calculateWidths()
+
+	t.printHeader(format)
+	for _, row := range t.rows {
+		t.printRow(format, row)
+	}
+}
+
+func (t *table) printHeader(format string) {
+	vals := t.applyWidths(t.header, t.widths)
+	if t.HeaderFormatter != nil {
+		txt := t.HeaderFormatter(format, vals...)
+		fmt.Fprint(t.Writer, txt)
+	} else {
+		fmt.Fprintf(t.Writer, format, vals...)
+	}
+}
+
+func (t *table) printRow(format string, r row) {
+	vals := t.applyWidths(r.s, t.widths)
+
+	if t.FirstColumnFormatter != nil {
+		vals[0] = t.FirstColumnFormatter("%s", vals[0])
+	}
+
+	// Apply custom row formatting
+	if r.f != nil {
+		for i := range vals {
+			vals[i] = r.f("%s", vals[i])
+		}
+	}
+
+	fmt.Fprintf(t.Writer, format, vals...)
+}
+
+func (t *table) calculateWidths() {
+	t.widths = make([]int, len(t.header))
+	for _, row := range t.rows {
+		for i, v := range row.s {
+			if w := t.Width(v) + t.Padding; w > t.widths[i] {
+				t.widths[i] = w
+			}
+		}
+	}
+
+	for i, v := range t.header {
+		if w := t.Width(v) + t.Padding; w > t.widths[i] {
+			t.widths[i] = w
+		}
+	}
+}
+
+func (t *table) applyWidths(row []string, widths []int) []interface{} {
+	out := make([]interface{}, len(row))
+	for i, s := range row {
+		out[i] = s + t.lenOffset(s, widths[i])
+	}
+	return out
+}
+
+func (t *table) lenOffset(s string, w int) string {
+	l := w - t.Width(s)
+	if l <= 0 {
+		return ""
+	}
+	return strings.Repeat(" ", l)
+}
+
+func max(i1, i2 int) int {
+	if i1 > i2 {
+		return i1
+	}
+	return i2
+}
+
+func safeOffset(sarr []string, idx int) string {
+	if idx >= len(sarr) {
+		return ""
+	}
+	return sarr[idx]
+}


Test patch:
diff --git a/cft/cft_test.go b/cft/cft_test.go
new file mode 100644
index 00000000..0f399d05
--- /dev/null
+++ b/cft/cft_test.go
@@ -0,0 +1,1 @@
+package cft
diff --git a/cft/parse/parse_test.go b/cft/parse/parse_test.go
index 2e9f83db..3e1f0612 100644
--- a/cft/parse/parse_test.go
+++ b/cft/parse/parse_test.go
@@ -212,3 +212,23 @@ Resources:
 	// Output:
 	// map[Resources:map[Bucket:map[Type:AWS::S3::Bucket]]]
 }
+
+func TestGetResource(t *testing.T) {
+	source := `
+Resources:
+    B:
+        Type: AWS::S3::Bucket
+`
+	template, err := parse.String(source)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	resource, err := template.GetResource("B")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if resource == nil {
+		t.Error("Unexpected: resource is nil")
+	}
+}
diff --git a/internal/cmd/cat/cat_test.go b/internal/cmd/cat/cat_test.go
index 5627b1ee..0e3e12ea 100644
--- a/internal/cmd/cat/cat_test.go
+++ b/internal/cmd/cat/cat_test.go
@@ -14,15 +14,12 @@ func Example_cat_help() {
 
 	cat.Cmd.Execute()
 	// Output:
-	// Downloads the template or the configuration file used to deploy <stack> and prints it to stdout.
-	//
-	// The  `--config` flag can be used to get the rain config file for the stack instead of the template.
+	// Downloads the template used to deploy <stack> and prints it to stdout.
 	//
 	// Usage:
 	//   cat <stack>
 	//
 	// Flags:
-	//   -c, --config        output the config file for the existing stack
 	//   -h, --help          help for cat
 	//   -t, --transformed   get the template with transformations applied by CloudFormation
 	//   -u, --unformatted   output the template in its raw form; do not attempt to format it
diff --git a/internal/cmd/ccdeploy/ccdeploy_test.go b/internal/cmd/cc/deploy_test.go
similarity index 95%
rename from internal/cmd/ccdeploy/ccdeploy_test.go
rename to internal/cmd/cc/deploy_test.go
index aab8f02c..e5f9eea6 100644
--- a/internal/cmd/ccdeploy/ccdeploy_test.go
+++ b/internal/cmd/cc/deploy_test.go
@@ -1,16 +1,15 @@
-package ccdeploy
+package cc
 
 import (
 	"testing"
 
 	"github.com/aws-cloudformation/rain/cft/diff"
 	"github.com/aws-cloudformation/rain/cft/graph"
-	"github.com/aws-cloudformation/rain/internal/config"
 )
 
 func TestReady(t *testing.T) {
 
-	config.Debug = true
+	// config.Debug = true
 
 	g := graph.Empty()
 
diff --git a/internal/cmd/cc/parse_sub_test.go b/internal/cmd/cc/parse_sub_test.go
new file mode 100644
index 00000000..f43a149b
--- /dev/null
+++ b/internal/cmd/cc/parse_sub_test.go
@@ -0,0 +1,71 @@
+package cc
+
+import (
+	"testing"
+
+	"github.com/aws-cloudformation/rain/internal/config"
+)
+
+func TestParseSub(t *testing.T) {
+	config.Debug = true
+
+	cases := make(map[string][]word, 0)
+
+	// Just a string
+	cases["ABC"] = []word{word{t: STR, w: "ABC"}}
+
+	// Basic use case with a Ref
+	cases["ABC-${XYZ}-123"] = []word{
+		word{t: STR, w: "ABC-"},
+		word{t: REF, w: "XYZ"},
+		word{t: STR, w: "-123"},
+	}
+
+	// Literal
+	cases["ABC-${!Literal}-123"] = []word{
+		word{t: STR, w: "ABC-${Literal}-123"},
+	}
+
+	// Variable by itself
+	cases["${ABC}"] = []word{word{t: REF, w: "ABC"}}
+
+	// GetAtt
+	cases["${ABC.XYZ}"] = []word{word{t: GETATT, w: "ABC.XYZ"}}
+
+	// AWS
+	cases["ABC${AWS::AccountId}XYZ"] = []word{
+		word{t: STR, w: "ABC"},
+		word{t: AWS, w: "AccountId"},
+		word{t: STR, w: "XYZ"},
+	}
+
+	// DOLLARS everywhere
+	cases["BAZ${ABC$XYZ}FOO$BAR"] = []word{
+		word{t: STR, w: "BAZ"},
+		word{t: REF, w: "ABC$XYZ"},
+		word{t: STR, w: "FOO$BAR"},
+	}
+
+	for sub, expect := range cases {
+		words, err := ParseSub(sub)
+		if err != nil {
+			t.Fatal(err)
+		}
+		config.Debugf("%v", words)
+		if len(expect) != len(words) {
+			t.Fatalf("\"%s\": words len is %v, expected %v", sub, len(words), len(expect))
+		}
+		for i, w := range expect {
+			if words[i].t != w.t || words[i].w != w.w {
+				t.Fatalf("\"%s\": got %v, expected %v", sub, words[i], w)
+			}
+		}
+	}
+
+	// Invalid strings should fail
+	sub := "${AAA"
+	words, err := ParseSub(sub)
+	if err == nil {
+		t.Fatalf("\"%s\": should have failed, got %v", sub, words)
+	}
+}
diff --git a/internal/cmd/cc/resolve_test.go b/internal/cmd/cc/resolve_test.go
new file mode 100644
index 00000000..e66c0411
--- /dev/null
+++ b/internal/cmd/cc/resolve_test.go
@@ -0,0 +1,482 @@
+package cc
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/aws-cloudformation/rain/cft/parse"
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/aws-cloudformation/rain/internal/dc"
+	"github.com/aws-cloudformation/rain/internal/node"
+	"github.com/aws-cloudformation/rain/internal/s11n"
+	"github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
+	"gopkg.in/yaml.v3"
+)
+
+// Test to make sure we can resolve Refs to Parameters
+func TestResolveRefParam(t *testing.T) {
+	source := `
+Parameters:
+    A:
+        Type: String
+    Missing:
+        Type: String
+        Default: a
+Resources:
+    B:
+        Type: AWS::S3::Bucket
+        Properties:
+            BucketName: 
+                Ref: A
+    C:
+        Type: AWS::S3::Bucket
+        Properties:
+            BucketName: !Ref Missing
+`
+	template, err := parse.String(source)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	config.Debug = true
+	config.Debugf("template: %v", node.ToSJson(template.Node))
+
+	// Set globals
+	deployedTemplate = template
+	stack := types.Stack{} // Not relevant here
+	stack.Parameters = make([]types.Parameter, 0)
+	testParams := make([]string, 0)
+	testTags := make([]string, 0)
+	testParams = append(testParams, "A=aaa")
+	dc, err := dc.GetDeployConfig(testTags, testParams, "", "",
+		template, stack, false, true, false)
+	if err != nil {
+		panic(err)
+	}
+	templateConfig = dc
+
+	resourceNode, err := template.GetResource("B")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	resource := NewResource("B", "AWS::S3::Bucket", Waiting, resourceNode)
+
+	resolved, err := Resolve(resource)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	config.Debugf("resolved node B: %v", node.ToSJson(resolved))
+
+	// Make sure the value is what we expect
+	_, props := s11n.GetMapValue(resolved, "Properties")
+	if props == nil {
+		t.Fatalf("B Properties is missing")
+	}
+	_, bucketName := s11n.GetMapValue(props, "BucketName")
+	if bucketName == nil {
+		t.Fatalf("B Properties BucketName is missing")
+	}
+	if bucketName.Value != "aaa" {
+		t.Fatalf("Expected BucketName for B to be aaa, got %v", bucketName.Value)
+	}
+
+	// Check a missing parameter to make sure the default is applied
+	resourceNode, err = template.GetResource("C")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	resource = NewResource("C", "AWS::S3::Bucket", Waiting, resourceNode)
+
+	resolved, err = Resolve(resource)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	config.Debugf("resolved node C: %v", node.ToSJson(resolved))
+
+	_, props = s11n.GetMapValue(resolved, "Properties")
+	if props == nil {
+		t.Fatalf("C Properties is missing")
+	}
+	_, bucketName = s11n.GetMapValue(props, "BucketName")
+	if bucketName == nil {
+		t.Fatalf("C Properties BucketName is missing")
+	}
+	if bucketName.Value != "a" {
+		t.Fatalf("Expected BucketName for C to be a, got %v", bucketName.Value)
+	}
+}
+
+// Test to make sure we can resolve Refs to Resources
+func TestResolveRefResource(t *testing.T) {
+	source := `
+Resources:
+    B:
+        Type: AWS::S3::Bucket
+        Properties:
+            BucketName: mybucket
+    C:
+        Type: AWS::S3::Bucket
+        Properties:
+            LoggingConfiguration:
+                DestinationBucketName: !Ref B
+`
+	template, err := parse.String(source)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	config.Debug = true
+	config.Debugf("template: %v", node.ToSJson(template.Node))
+
+	// Set globals
+	deployedTemplate = template
+	stack := types.Stack{} // Not relevant here
+	stack.Parameters = make([]types.Parameter, 0)
+	testParams := make([]string, 0)
+	testTags := make([]string, 0)
+	dc, err := dc.GetDeployConfig(testTags, testParams, "", "",
+		template, stack, false, true, false)
+	if err != nil {
+		panic(err)
+	}
+	templateConfig = dc
+
+	resourceNode, err := template.GetResource("C")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	resource := NewResource("C", "AWS::S3::Bucket", Waiting, resourceNode)
+
+	// Put B into the resource map, as if we had deployed it
+	bNode, _ := deployedTemplate.GetResource("B")
+	bResource := NewResource("B", "AWS::S3::Bucket", Waiting, bNode)
+	bResource.Identifier = "bname"
+	bResource.Model = `
+{
+	"BucketName": "bname"	
+}
+`
+	resMap["B"] = bResource
+
+	resolved, err := Resolve(resource)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	config.Debugf("resolved node C: %v", node.ToSJson(resolved))
+
+	// Make sure the value is what we expect
+	gotVal := resolved.Content[3].Content[1].Content[1].Value
+	if gotVal != "bname" {
+		t.Fatalf("Expected DestinationBucketName to be bname, got %s", gotVal)
+	}
+}
+
+// Test to make sure we can resolve GetAtts
+func TestResolveGetAtt(t *testing.T) {
+	source := `
+Resources:
+    MyFunc:
+        Type: AWS::Lambda::Function
+        Properties:
+            Role: 
+                Fn::GetAtt: [ MyBucket, Arn ]
+    MyFunc2:
+        Type: AWS::Lambda::Function
+        Properties:
+            Role: !GetAtt MyBucket.Arn
+    MyBucket:
+        Type: AWS::S3::Bucket
+`
+
+	// Note that MyFunc and MyFunc2 look the same in the parsed YAML
+
+	template, err := parse.String(source)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	config.Debug = true
+	config.Debugf("template: %v", node.ToSJson(template.Node))
+
+	// Set globals
+	deployedTemplate = template
+	stack := types.Stack{} // Not relevant here
+	stack.Parameters = make([]types.Parameter, 0)
+	testParams := make([]string, 0)
+	testTags := make([]string, 0)
+	dc, err := dc.GetDeployConfig(testTags, testParams, "", "",
+		template, stack, false, true, false)
+	if err != nil {
+		panic(err)
+	}
+	templateConfig = dc
+
+	logicalId := "MyBucket"
+	bNode, err := template.GetResource(logicalId)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Put it into the resource map, as if we had deployed it
+	bResource := NewResource(logicalId, "AWS::S3::Bucket", Waiting, bNode)
+	bResource.Identifier = "bname"
+	arn := "arn:aws:s3:::bname"
+	bResource.Model = `
+{
+	"BucketName": "bname",
+	"Arn": "ARN" 
+}
+`
+	bResource.Model = strings.Replace(bResource.Model, "ARN", arn, -1)
+	config.Debugf("bResource.Model: %s", bResource.Model)
+	resMap[logicalId] = bResource
+
+	myfuncNode, _ := template.GetResource("MyFunc")
+	myfunc := NewResource("MyFunc", "AWS::Lambda::Function", Waiting, myfuncNode)
+
+	resolved, err := Resolve(myfunc)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	config.Debugf("resolved MyFunc node: %v", node.ToSJson(resolved))
+
+	// Make sure the value is what we expect
+	if resolved.Content[3].Content[1].Kind != yaml.ScalarNode {
+		t.Fatalf("Expected resolved Arn to be a scalar")
+	}
+	gotVal := resolved.Content[3].Content[1].Value
+	if gotVal != arn {
+		t.Fatalf("Expected %s but got %s", arn, gotVal)
+	}
+}
+
+// Test to make sure we can resolve Subs
+func TestResolveSub(t *testing.T) {
+	source := `
+Resources:
+    MyFunc:
+        Type: AWS::Lambda::Function
+        Properties:
+            Role: 
+                Fn::Sub: "${MyBucket.Arn}"
+    MyFunc2:
+        Type: AWS::Lambda::Function
+        Properties:
+            Role: !Sub "${MyBucket.Arn}"
+    MyBucket:
+        Type: AWS::S3::Bucket
+        Properties:
+            BucketName: 
+                Fn::Sub:
+                  - "Test${A}"
+                  - A: 1
+`
+
+	// Note that MyFunc and MyFunc2 look the same in the parsed YAML
+
+	template, err := parse.String(source)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	config.Debug = true
+	config.Debugf("template: %v", node.ToSJson(template.Node))
+
+	// Set globals
+	deployedTemplate = template
+	stack := types.Stack{} // Not relevant here
+	stack.Parameters = make([]types.Parameter, 0)
+	testParams := make([]string, 0)
+	testTags := make([]string, 0)
+	dc, err := dc.GetDeployConfig(testTags, testParams, "", "",
+		template, stack, false, true, false)
+	if err != nil {
+		panic(err)
+	}
+	templateConfig = dc
+
+	logicalId := "MyBucket"
+	bNode, err := template.GetResource(logicalId)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Put it into the resource map, as if we had deployed it
+	bResource := NewResource(logicalId, "AWS::S3::Bucket", Waiting, bNode)
+	bResource.Identifier = "Test1"
+	arn := "arn:aws:s3:::Test"
+	bResource.Model = `
+{
+	"BucketName": "Test1",
+	"Arn": "ARN" 
+}
+`
+	bResource.Model = strings.Replace(bResource.Model, "ARN", arn, -1)
+	config.Debugf("bResource.Model: %s", bResource.Model)
+	resMap[logicalId] = bResource
+
+	myfuncNode, _ := template.GetResource("MyFunc")
+	myfunc := NewResource("MyFunc", "AWS::Lambda::Function", Waiting, myfuncNode)
+
+	resolved, err := Resolve(myfunc)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	config.Debugf("resolved MyFunc node: %v", node.ToSJson(resolved))
+
+	// Make sure the value is what we expect
+	if resolved.Content[3].Content[1].Kind != yaml.ScalarNode {
+		t.Fatalf("Expected resolved Arn to be a scalar")
+	}
+	gotVal := resolved.Content[3].Content[1].Value
+	if gotVal != arn {
+		t.Fatalf("Expected %s but got %s", arn, gotVal)
+	}
+
+	// Now resolve the Sub with a Sequence in MyBucket
+	resolved, err = Resolve(bResource)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	config.Debugf("resolved MyBucket node: %v", node.ToSJson(resolved))
+
+	gotVal = resolved.Content[3].Content[1].Value
+	if gotVal != "Test1" {
+		t.Fatalf("Got %s for MyBucket Name, expected Test1", gotVal)
+	}
+}
+
+// Test to make sure we can resolve Refs to pseudo params (AWS::X)
+// Commenting this out since it should actually be an integ test.
+// It has to make network calls to get aws config.
+// TODO: Move to integ test
+/*
+func TestResolvePseudo(t *testing.T) {
+	source := `
+Resources:
+    A:
+        Type: A::B::C
+        Properties:
+            AccountId: !Sub "${AWS::AccountId}"
+            Region: !Sub "${AWS::Region}"
+            Partition: !Sub "${AWS::Partition}"
+`
+
+	template, err := parse.String(source)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	config.Debug = true
+	config.Debugf("template: %v", node.ToSJson(template.Node))
+
+	// Set globals
+	deployedTemplate = template
+	stack := types.Stack{} // Not relevant here
+	stack.Parameters = make([]types.Parameter, 0)
+	testParams := make([]string, 0)
+	testTags := make([]string, 0)
+	dc, err := dc.GetDeployConfig(testTags, testParams, "", "",
+		template, stack, false, true, false)
+	if err != nil {
+		panic(err)
+	}
+	templateConfig = dc
+
+	logicalName := "A"
+
+	resourceNode, err := template.GetResource(logicalName)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	resource := NewResource(logicalName, "AWS::S3::Bucket", Waiting, resourceNode)
+
+	resolved, err := Resolve(resource)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	config.Debugf("resolved node : %v", node.ToSJson(resolved))
+
+	// TODO: Verify values
+}
+*/
+
+// Test to make sure we can resolve Sequences
+func TestResolveSequence(t *testing.T) {
+	source := `
+Resources:
+  Log:
+    Type: AWS::Logs::LogGroup
+    Properties:
+      LogGroupName: test
+  Query1:
+    Type: AWS::Logs::QueryDefinition
+    Properties:
+      Name: Query1
+      QueryString: "fields @message"
+      LogGroupNames:
+      - !Sub "${Log.LogGroupName}"
+      - !Ref Log 
+`
+	template, err := parse.String(source)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	config.Debug = true
+	config.Debugf("template: %v", node.ToSJson(template.Node))
+
+	// Set globals
+	deployedTemplate = template
+	stack := types.Stack{} // Not relevant here
+	stack.Parameters = make([]types.Parameter, 0)
+	testParams := make([]string, 0)
+	testTags := make([]string, 0)
+	dc, err := dc.GetDeployConfig(testTags, testParams, "", "",
+		template, stack, false, true, false)
+	if err != nil {
+		panic(err)
+	}
+	templateConfig = dc
+
+	logicalName := "Query1"
+
+	resourceNode, err := template.GetResource(logicalName)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	resource := NewResource(logicalName, "AWS::Logs::QueryDefinition", Waiting, resourceNode)
+
+	// Put Log into the resource map, as if we had deployed it
+	rName := "Log"
+	rNode, _ := deployedTemplate.GetResource(rName)
+	rResource := NewResource(rName, "AWS::Logs::LogGoup", Waiting, rNode)
+	rResource.Identifier = "test"
+	rResource.Model = `
+{
+	"LogGroupName": "test"	
+}
+`
+	resMap[rName] = rResource
+
+	resolved, err := Resolve(resource)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	config.Debugf("resolved node: %v", node.ToSJson(resolved))
+
+	// Make sure the value is what we expect
+}
diff --git a/internal/cmd/ccdeploy/update_test.go b/internal/cmd/cc/update_test.go
similarity index 97%
rename from internal/cmd/ccdeploy/update_test.go
rename to internal/cmd/cc/update_test.go
index 9455581f..dc721d42 100644
--- a/internal/cmd/ccdeploy/update_test.go
+++ b/internal/cmd/cc/update_test.go
@@ -1,4 +1,4 @@
-package ccdeploy
+package cc
 
 import (
 	"testing"
diff --git a/internal/cmd/rain/rain_test.go b/internal/cmd/rain/rain_test.go
index 8f72cd7d..ded5accb 100644
--- a/internal/cmd/rain/rain_test.go
+++ b/internal/cmd/rain/rain_test.go
@@ -20,8 +20,7 @@ func Example_rainhelp() {
 	//
 	// Stack commands:
 	//   cat         Get the CloudFormation template from a running stack
-	//   ccdeploy    Deploy a local template directly using the Cloud Control API (Experimental!)
-	//   ccrm        Delete a deployment created by ccdeploy (Experimental!)
+	//   cc          Interact with templates using Cloud Control API instead of CloudFormation
 	//   deploy      Deploy a CloudFormation stack from a local template
 	//   logs        Show the event log for the named stack
 	//   ls          List running CloudFormation stacks
diff --git a/internal/dc/dc_test.go b/internal/dc/dc_test.go
index 0c95a53d..508faccb 100644
--- a/internal/dc/dc_test.go
+++ b/internal/dc/dc_test.go
@@ -1,12 +1,9 @@
-package dc
+package dc_test
 
 import (
-	"fmt"
-	"github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
-	"github.com/aws/smithy-go/ptr"
-	"strings"
 	"testing"
 
+	"github.com/aws-cloudformation/rain/internal/dc"
 	"github.com/google/go-cmp/cmp"
 )
 
@@ -24,73 +21,10 @@ func TestListToMap(t *testing.T) {
 	}
 
 	for _, testCase := range testCases {
-		actual := ListToMap("test", testCase.input)
+		actual := dc.ListToMap("test", testCase.input)
 
 		if d := cmp.Diff(testCase.expected, actual); d != "" {
 			t.Errorf(d)
 		}
 	}
 }
-
-func TestConfigFromStack(t *testing.T) {
-	// Prepare test cases with a stack name and a map of parameters and tags
-	testCases := []struct {
-		testCaseName     string            // The name of the stack
-		params           map[string]string // The expected parameters
-		tags             map[string]string // The expected tags
-		yamlString       string            // The expected yaml string
-		exactStringMatch bool              // If true, the config must match the yamlString exactly. Otherwise, the config is validated tag by tag, and param by param
-	}{
-		{"test-stack-one-tag-one-param", map[string]string{"Foo": "bar"}, map[string]string{"Baz": "quux"}, "Parameters:\n  Foo: bar\nTags:\n  Baz: quux\n", true},
-		{"test-stack-multiple-tags-and-params-1", map[string]string{"Foo": "bar", "Baz": "quux"}, map[string]string{"Mooz": "xyzzy", "Garply": "thud"}, "Parameters:\n  Foo: bar\n  Baz: quux\nTags:\n  Mooz: xyzzy\n  Garply: thud\n", false},
-		{"test-stack-multiple-tags-and-params-2", map[string]string{"Foo": "bar", "Baz": "quux", "Mooz": "xyzzy"}, map[string]string{"Garply": "thud"}, "Parameters:\n  Foo: bar\n  Baz: quux\n  Mooz: xyzzy\nTags:\n  Garply: thud\n", false},
-		{"test-stack-multiple-tags-and-params-3", map[string]string{"Foo": "bar", "Baz": "quux", "Mooz": "xyzzy", "Garply": "thud"}, map[string]string{}, "Parameters:\n  Foo: bar\n  Baz: quux\n  Mooz: xyzzy\n  Garply: thud\nTags: {}\n", false},
-		{"test-stack-with-no-tags", map[string]string{"Foo": "bar"}, map[string]string{}, "Parameters:\n  Foo: bar\nTags: {}\n", false},
-		{"test-stack-with-no-params", map[string]string{}, map[string]string{"Foo": "bar", "Baz": "quux", "Mooz": "xyzzy", "Garply": "thud"}, "Parameters: {}\nTags:\n  Foo: bar\n  Baz: quux\n  Mooz: xyzzy\n  Garply: thud\n", false},
-		{"test-stack-with-no-tags-and-params", map[string]string{}, map[string]string{}, "Parameters: {}\nTags: {}\n", true},
-	}
-	// Iterate over test cases
-	for _, testCase := range testCases {
-		// Create a stack with the given name, parameters and tags
-		stack := types.Stack{
-			Tags:       []types.Tag{},
-			Parameters: []types.Parameter{},
-		}
-		for key, value := range testCase.tags {
-			stack.Tags = append(stack.Tags, types.Tag{Key: ptr.String(key), Value: ptr.String(value)})
-		}
-		for key, value := range testCase.params {
-			stack.Parameters = append(stack.Parameters, types.Parameter{ParameterKey: ptr.String(key), ParameterValue: ptr.String(value)})
-		}
-
-		// Get the config from the stack
-		config, err := ConfigFromStack(stack)
-		if err != nil {
-			t.Errorf("case %s - expected no error, got '%s'", testCase.testCaseName, err)
-		}
-
-		// Check if the config matches the expected string
-		if testCase.exactStringMatch {
-			if config != testCase.yamlString {
-				t.Errorf("case %s - expected '%s', got '%s'", testCase.testCaseName, testCase.yamlString, config)
-			}
-		} else {
-			// For each tag in testcase, check if it is present in the config
-			for key, value := range testCase.tags {
-				expectedString := fmt.Sprintf("%s: %s", key, value)
-				if strings.Contains(config, fmt.Sprintf("%s: %s", key, value)) == false {
-					t.Errorf("case %s - expected '%s' in '%s'", testCase.testCaseName, expectedString, testCase.yamlString)
-				}
-			}
-
-			// For each param in testcase, check if it is present in the config
-			for key, value := range testCase.params {
-				expectedString := fmt.Sprintf("%s: %s", key, value)
-				if strings.Contains(config, expectedString) == false {
-					t.Errorf("case %s - expected '%s' in '%s'", testCase.testCaseName, expectedString, testCase.yamlString)
-				}
-			}
-		}
-
-	}
-}
diff --git a/internal/table/table_test.go b/internal/table/table_test.go
new file mode 100644
index 00000000..e4f89287
--- /dev/null
+++ b/internal/table/table_test.go
@@ -0,0 +1,274 @@
+package table
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/google/go-cmp/cmp"
+	"github.com/mattn/go-runewidth"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestFormatter(t *testing.T) {
+	t.Parallel()
+
+	var formatter Formatter
+
+	fn := func(a string, b ...interface{}) string { return "" }
+	f := Formatter(fn)
+
+	assert.IsType(t, formatter, f)
+}
+
+func TestTable_New(t *testing.T) {
+	t.Parallel()
+
+	buf := bytes.Buffer{}
+	New("foo", "bar").WithWriter(&buf).Print()
+	out := buf.String()
+
+	assert.Contains(t, out, "foo")
+	assert.Contains(t, out, "bar")
+
+	buf.Reset()
+	New().WithWriter(&buf).Print()
+	out = buf.String()
+
+	assert.Empty(t, strings.TrimSpace(out))
+}
+
+func TestTable_WithHeaderFormatter(t *testing.T) {
+	t.Parallel()
+
+	uppercase := func(f string, v ...interface{}) string {
+		return strings.ToUpper(fmt.Sprintf(f, v...))
+	}
+	buf := bytes.Buffer{}
+
+	tbl := New("foo", "bar").WithWriter(&buf).WithHeaderFormatter(uppercase)
+	tbl.Print()
+	out := buf.String()
+
+	assert.Contains(t, out, "FOO")
+	assert.Contains(t, out, "BAR")
+
+	buf.Reset()
+	tbl.WithHeaderFormatter(nil).Print()
+	out = buf.String()
+
+	assert.Contains(t, out, "foo")
+	assert.Contains(t, out, "bar")
+}
+
+func TestTable_WithFirstColumnFormatter(t *testing.T) {
+	t.Parallel()
+
+	uppercase := func(f string, v ...interface{}) string {
+		return strings.ToUpper(fmt.Sprintf(f, v...))
+	}
+
+	buf := bytes.Buffer{}
+
+	tbl := New("foo", "bar").WithWriter(&buf).WithFirstColumnFormatter(uppercase).AddRow("fizz", "buzz")
+	tbl.Print()
+	out := buf.String()
+
+	config.Debug = true
+	config.Debugf(out)
+
+	assert.Contains(t, out, "foo")
+	assert.Contains(t, out, "bar")
+	assert.Contains(t, out, "FIZZ")
+	assert.Contains(t, out, "buzz")
+
+	buf.Reset()
+	tbl.WithFirstColumnFormatter(nil).Print()
+	out = buf.String()
+
+	assert.Contains(t, out, "fizz")
+	assert.Contains(t, out, "buzz")
+}
+
+func TestTable_WithPadding(t *testing.T) {
+	t.Parallel()
+
+	// zero value
+	buf := bytes.Buffer{}
+	tbl := New("foo", "bar").WithWriter(&buf).WithPadding(0)
+	tbl.Print()
+	out := buf.String()
+	assert.Contains(t, out, "foobar")
+
+	// positive value
+	buf.Reset()
+	tbl.WithPadding(4).Print()
+	out = buf.String()
+	assert.Contains(t, out, "foo    bar    ")
+
+	// negative value
+	buf.Reset()
+	tbl.WithPadding(-1).Print()
+	out = buf.String()
+	assert.Contains(t, out, "foobar")
+}
+
+func TestTable_WithWriter(t *testing.T) {
+	t.Parallel()
+
+	// not that we haven't been using it in all these tests but:
+	buf := bytes.Buffer{}
+	New("foo", "bar").WithWriter(&buf).Print()
+	assert.NotEmpty(t, buf.String())
+
+	stdout := os.Stdout
+	temp, _ := ioutil.TempFile("", "")
+	os.Stdout = temp
+	defer func() {
+		os.Stdout = stdout
+		temp.Close()
+	}()
+
+	New("foo", "bar").WithWriter(nil).Print()
+	temp.Seek(0, 0)
+
+	out, _ := ioutil.ReadAll(temp)
+	assert.NotEmpty(t, out)
+}
+
+func TestTable_AddRow(t *testing.T) {
+	t.Parallel()
+
+	buf := bytes.Buffer{}
+	tbl := New("foo", "bar").WithWriter(&buf).AddRow("fizz", "buzz")
+	tbl.Print()
+	out := buf.String()
+	assert.Contains(t, out, "fizz")
+	assert.Contains(t, out, "buzz")
+	lines := strings.Count(out, "\n")
+
+	// empty should add empty line
+	buf.Reset()
+	tbl.AddRow().Print()
+	assert.Equal(t, lines+1, strings.Count(buf.String(), "\n"))
+
+	// less than one will fill left-to-right
+	buf.Reset()
+	tbl.AddRow("cat").Print()
+	assert.Contains(t, buf.String(), "\ncat")
+
+	// more than initial length are truncated
+	buf.Reset()
+	tbl.AddRow("bippity", "boppity", "boo").Print()
+	assert.NotContains(t, buf.String(), "boo")
+
+	// check the full table
+	buf.Reset()
+	tbl.Print()
+	expected := `foo      bar      
+fizz     buzz     
+                  
+cat               
+bippity  boppity  
+`
+	if diff := cmp.Diff(expected, buf.String()); diff != "" {
+		t.Fatalf("table mismatch (-expected +got):\n%s\nout=%#v", diff, buf.String())
+	}
+}
+
+func TestTable_AddRow_WithNewLines(t *testing.T) {
+	t.Parallel()
+
+	buf := bytes.Buffer{}
+	tbl := New("foo", "bar").WithWriter(&buf).AddRow("fizz", "buzz")
+
+	// Add some rows
+	tbl.AddRow()
+	tbl.AddRow("cat")
+
+	// add an entry that contains new lines
+	tbl.AddRow("bippity", "boppity\nboop")
+
+	// Add a couple more rows
+	tbl.AddRow("a", "b")
+	tbl.AddRow("c", "d")
+
+	// and another entry with more new lines
+	tbl.AddRow("1\n2", "x\ny\nz")
+
+	// check the full table
+	buf.Reset()
+	tbl.Print()
+	expected := `foo      bar      
+fizz     buzz     
+                  
+cat               
+bippity  boppity  
+         boop     
+a        b        
+c        d        
+1        x        
+2        y        
+         z        
+`
+	if diff := cmp.Diff(expected, buf.String()); diff != "" {
+		t.Fatalf("table mismatch (-expected +got):\n%s\nout=%#v", diff, buf.String())
+	}
+}
+
+func TestTable_SetRows(t *testing.T) {
+	t.Parallel()
+
+	buf := bytes.Buffer{}
+	tbl := New("foo", "bar").WithWriter(&buf).SetRows([][]string{
+		{"fizz", "buzz"},
+		{"lorem", "ipsum"},
+	})
+	tbl.Print()
+	out := buf.String()
+	assert.Contains(t, out, "fizz")
+	assert.Contains(t, out, "buzz")
+	assert.Contains(t, out, "lorem")
+	assert.Contains(t, out, "ipsum")
+	assert.Equal(t, 3, strings.Count(out, "\n"))
+
+	// empty should remove all rows
+	buf.Reset()
+	tbl.SetRows([][]string{}).Print()
+	assert.Equal(t, 1, strings.Count(buf.String(), "\n"))
+
+	// less than one will fill left-to-right
+	buf.Reset()
+	tbl.SetRows([][]string{{"cat"}}).Print()
+	assert.Contains(t, buf.String(), "\ncat")
+
+	// more than initial length are truncated
+	buf.Reset()
+	tbl.SetRows([][]string{
+		{"lorem", "ipsum"},
+		{"bippity", "boppity", "boo"},
+	}).Print()
+	assert.NotContains(t, buf.String(), "boo")
+}
+
+func TestTable_WithWidthFunc(t *testing.T) {
+	t.Parallel()
+
+	buf := bytes.Buffer{}
+
+	New("", "").
+		WithWriter(&buf).
+		WithPadding(1).
+		WithWidthFunc(runewidth.StringWidth).
+		AddRow("请求", "alpha").
+		AddRow("abc", "beta").
+		Print()
+
+	actual := buf.String()
+	assert.Contains(t, actual, "请求 alpha")
+	assert.Contains(t, actual, "abc  beta")
+}
diff --git a/test/templates/intrinsic.yaml b/test/templates/intrinsic.yaml
new file mode 100644
index 00000000..d3041c35
--- /dev/null
+++ b/test/templates/intrinsic.yaml
@@ -0,0 +1,24 @@
+Parameters:
+  LogName: 
+    Type: String
+Resources:
+  Log:
+    Type: AWS::Logs::LogGroup
+    Properties:
+      LogGroupName: !Ref LogName
+  Query1:
+    Type: AWS::Logs::QueryDefinition
+    Properties:
+      Name: Query1
+      QueryString: "fields @message"
+      LogGroupNames:
+      - !Sub "${Log.LogGroupName}"
+  Query2:
+    Type: AWS::Logs::QueryDefinition
+    Properties:
+      Name: Query2
+      QueryString: "fields @timestamp"
+      LogGroupNames:
+      - !Sub "${Log.LogGroupName}"
+
+
diff --git a/test/templates/intrinsic2.yaml b/test/templates/intrinsic2.yaml
new file mode 100644
index 00000000..5610209d
--- /dev/null
+++ b/test/templates/intrinsic2.yaml
@@ -0,0 +1,24 @@
+Parameters:
+  LogName: 
+    Type: String
+Resources:
+  Log:
+    Type: AWS::Logs::LogGroup
+    Properties:
+      LogGroupName: !Ref LogName
+  Query1:
+    Type: AWS::Logs::QueryDefinition
+    Properties:
+      Name: Query1
+      QueryString: "fields @timestamp, @message"
+      LogGroupNames:
+      - !Sub "${Log.LogGroupName}"
+  Query3:
+    Type: AWS::Logs::QueryDefinition
+    Properties:
+      Name: Query2
+      QueryString: "fields @timestamp"
+      LogGroupNames:
+      - !Sub "${Log.LogGroupName}"
+
+