Repository: aws-cloudformation/rain
Pull request #207 (2023-10-12 23:18:19)

Deploy templates with CCAPI
This is a highly experimental draft to prototype the idea of using the Cloud Control API to deploy templates, instead of submitting them to CloudFormation.


Patch:
diff --git a/cft/cft.go b/cft/cft.go
index a7ec09b6..88a743e2 100644
--- a/cft/cft.go
+++ b/cft/cft.go
@@ -15,6 +15,13 @@ type Template struct {
 	*yaml.Node
 }
 
+// TODO - We really need a convenient Template data structure
+// that lets us easily access elements.
+// t.Resources["MyResource"].Properties["MyProp"]
+//
+// Add a Model attribute to the struct and an Init function to populate it.
+// t.Model.Resources
+
 // Map returns the template as a map[string]interface{}
 func (t Template) Map() map[string]interface{} {
 	var out map[string]interface{}
@@ -26,3 +33,12 @@ func (t Template) Map() map[string]interface{} {
 
 	return out
 }
+
+// AppendStateMap appends a "State" section to the template
+func AppendStateMap(state Template) *yaml.Node {
+	state.Node.Content[0].Content = append(state.Node.Content[0].Content,
+		&yaml.Node{Kind: yaml.ScalarNode, Value: "State"})
+	stateMap := &yaml.Node{Kind: yaml.MappingNode, Content: make([]*yaml.Node, 0)}
+	state.Node.Content[0].Content = append(state.Node.Content[0].Content, stateMap)
+	return stateMap
+}
diff --git a/cft/diff/diff.go b/cft/diff/diff.go
index b6b790d2..15fc596f 100644
--- a/cft/diff/diff.go
+++ b/cft/diff/diff.go
@@ -6,6 +6,8 @@ import (
 	"fmt"
 	"sort"
 	"strings"
+
+	"github.com/aws-cloudformation/rain/internal/config"
 )
 
 // Mode represents a diff mode
@@ -47,7 +49,7 @@ type Diff interface {
 	Format(bool) string
 
 	// Value returns the value represented by the Diff
-	value() interface{}
+	Value() interface{}
 }
 
 // value represents a difference between values of any type
@@ -62,13 +64,13 @@ func (v value) Mode() Mode {
 }
 
 // value returns the value's value ;)
-func (v value) value() interface{} {
+func (v value) Value() interface{} {
 	return v.val
 }
 
 // String returns a string representation of the value
 func (v value) String() string {
-	return fmt.Sprintf("%s%v", v.Mode(), v.value())
+	return fmt.Sprintf("%s%v", v.Mode(), v.Value())
 }
 
 // slice represents a difference between two slices
@@ -86,11 +88,11 @@ func (s slice) Mode() Mode {
 }
 
 // value returns the slice's value
-func (s slice) value() interface{} {
+func (s slice) Value() interface{} {
 	out := make([]interface{}, len(s))
 
 	for i, v := range s {
-		out[i] = v.value()
+		out[i] = v.Value()
 	}
 
 	return out
@@ -122,11 +124,11 @@ func (m dmap) Mode() Mode {
 }
 
 // value returns the dmap's value
-func (m dmap) value() interface{} {
+func (m dmap) Value() interface{} {
 	out := make(map[string]interface{})
 
 	for k, v := range m {
-		out[k] = v.value()
+		out[k] = v.Value()
 	}
 
 	return out
@@ -160,3 +162,40 @@ func (m dmap) String() string {
 
 	return fmt.Sprintf("%smap[%s]", m.Mode(), strings.Join(parts, " "))
 }
+
+type ActionType string
+
+const (
+	Create ActionType = "Create"
+	Update ActionType = "Update"
+	Delete ActionType = "Delete"
+	None   ActionType = "None"
+)
+
+func GetResourceActions(d Diff) map[string]ActionType {
+	actions := make(map[string]ActionType)
+
+	dm := d.(dmap)
+	for k, v := range dm {
+		if k == "Resources" {
+			vm := v.(dmap)
+			for rname, resource := range vm {
+				config.Debugf("ResourceAction %v %v", rname, resource)
+				switch resource.Mode() {
+				case Added:
+					actions[rname] = Create
+				case Removed:
+					actions[rname] = Delete
+				case Involved:
+					actions[rname] = Update
+				case Unchanged:
+					actions[rname] = None
+				case Changed:
+					actions[rname] = Update
+				}
+			}
+		}
+	}
+
+	return actions
+}
diff --git a/cft/diff/format.go b/cft/diff/format.go
index c8223801..231eda5d 100644
--- a/cft/diff/format.go
+++ b/cft/diff/format.go
@@ -28,7 +28,7 @@ func (v value) Format(long bool) string {
 	e := yaml.NewEncoder(&buf)
 	e.SetIndent(2)
 
-	err := e.Encode(v.value())
+	err := e.Encode(v.Value())
 	if err != nil {
 		panic(err)
 	}
@@ -37,7 +37,7 @@ func (v value) Format(long bool) string {
 }
 
 func stubValue(v value) string {
-	switch v.value().(type) {
+	switch v.Value().(type) {
 	case map[string]interface{}:
 		return "{...}"
 	case []interface{}:
@@ -114,7 +114,7 @@ func formatSub(d Diff, path []interface{}, long bool) string {
 
 	v, isValue := d.(value)
 	if isValue {
-		k := reflect.ValueOf(v.value()).Kind()
+		k := reflect.ValueOf(v.Value()).Kind()
 
 		if k != reflect.Array && k != reflect.Map && k != reflect.Slice {
 			return fmt.Sprintf(" %s\n", formatted)
diff --git a/cft/format/format.go b/cft/format/format.go
index 244f2712..3174335f 100644
--- a/cft/format/format.go
+++ b/cft/format/format.go
@@ -120,3 +120,8 @@ func String(t cft.Template, opt Options) string {
 
 	return out + "\n"
 }
+
+// CftToYaml converts a template to a YAML string
+func CftToYaml(t cft.Template) string {
+	return String(t, Options{JSON: false, Unsorted: false})
+}
diff --git a/cft/format/json.go b/cft/format/json.go
index cc5b17c9..f6652472 100644
--- a/cft/format/json.go
+++ b/cft/format/json.go
@@ -41,24 +41,24 @@ func handleScalar(node *yaml.Node) interface{} {
 	return out
 }
 
-func jsonise(node *yaml.Node) interface{} {
+func Jsonise(node *yaml.Node) interface{} {
 	switch node.Kind {
 	case yaml.DocumentNode:
-		return jsonise(node.Content[0])
+		return Jsonise(node.Content[0])
 	case yaml.MappingNode:
 		out := make(mapslice.MapSlice, len(node.Content)/2)
 		for i := 0; i < len(node.Content); i += 2 {
 			key, value := node.Content[i], node.Content[i+1]
 			out[i/2] = mapslice.MapItem{
-				Key:   jsonise(key),
-				Value: jsonise(value),
+				Key:   Jsonise(key),
+				Value: Jsonise(value),
 			}
 		}
 		return out
 	case yaml.SequenceNode:
 		out := make([]interface{}, len(node.Content))
 		for i, n := range node.Content {
-			out[i] = jsonise(n)
+			out[i] = Jsonise(n)
 		}
 		return out
 	default:
@@ -79,7 +79,7 @@ func convertToJSON(in string) string {
 		panic(err)
 	}
 
-	intermediate := jsonise(&d)
+	intermediate := Jsonise(&d)
 
 	out, err := json.MarshalIndent(&intermediate, "", "    ")
 	if err != nil {
diff --git a/cft/graph/graph.go b/cft/graph/graph.go
index fb2f587a..5f234f1c 100644
--- a/cft/graph/graph.go
+++ b/cft/graph/graph.go
@@ -32,6 +32,14 @@ type Graph struct {
 	order []Node
 }
 
+// Empty returns a new, empty graph
+func Empty() Graph {
+	return Graph{
+		nodes: make(map[Node]map[Node]bool),
+		order: make([]Node, 0),
+	}
+}
+
 // New returns a Graph representing the connections
 // between elements in the provided template.
 // The type of each item in the graph is Node
@@ -64,7 +72,7 @@ func New(t cft.Template) Graph {
 		if entityTree, ok := entity.(map[string]interface{}); ok {
 			for fromName, res := range entityTree {
 				from := Node{typeName, fromName}
-				graph.link(from)
+				graph.Link(from)
 
 				resource := res.(map[string]interface{})
 				for _, toName := range getRefs(resource) {
@@ -81,7 +89,7 @@ func New(t cft.Template) Graph {
 						}
 					}
 
-					graph.link(from, Node{toType, toName})
+					graph.Link(from, Node{toType, toName})
 				}
 			}
 		}
@@ -113,7 +121,7 @@ func (g *Graph) add(item Node) {
 }
 
 // link creates a connection between two nodes in the graph
-func (g *Graph) link(item Node, links ...Node) {
+func (g *Graph) Link(item Node, links ...Node) {
 	g.add(item)
 
 	for _, to := range links {
diff --git a/cft/graph/graph.yaml b/cft/graph/graph.yaml
new file mode 100644
index 00000000..970d18a7
--- /dev/null
+++ b/cft/graph/graph.yaml
@@ -0,0 +1,27 @@
+Parameters:
+
+  N:
+    Type: String
+    Default: "A"
+
+Resources:
+
+  A:
+    Type: AWS::S3::Bucket
+    DependsOn: B
+    Properties:
+      BucketName: !Ref N
+
+  B: 
+    Type: AWS::S3::Bucket
+
+  C:
+    Type: AWS::S3::Bucket
+    DependsOn: [B, D]
+
+  D:
+    Type: AWS::S3::Bucket
+    DependsOn: E
+
+  E: 
+    Type: AWS::S3::Bucket
diff --git a/go.mod b/go.mod
index 8b295671..4ffa4a0b 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.18
 
 require (
 	github.com/ake-persson/mapslice-json v0.0.0-20210720081907-22c8edf57807
+	github.com/appscode/jsonpatch v1.0.1
 	github.com/aws/aws-sdk-go-v2 v1.22.2
 	github.com/aws/aws-sdk-go-v2/config v1.23.0
 	github.com/aws/aws-sdk-go-v2/credentials v1.15.2
diff --git a/go.sum b/go.sum
index 256f5fa5..dd96fba5 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
 github.com/ake-persson/mapslice-json v0.0.0-20210720081907-22c8edf57807 h1:w3nrGk00TWs/4iZ3Q0k9c0vL0e/wRziArKU4e++d/nA=
 github.com/ake-persson/mapslice-json v0.0.0-20210720081907-22c8edf57807/go.mod h1:fGnnfniJiO/ajHAVHqMSUSL8sE9LmU9rzclCtoeB+y8=
+github.com/appscode/jsonpatch v1.0.1 h1:e82Bj+rsBSnpsmjiIGlc9NiKSBpJONZkamk/F8GrCR0=
+github.com/appscode/jsonpatch v1.0.1/go.mod h1:4AJxUpXUhv4N+ziTvIcWWXgeorXpxPZOfk9HdEVr96M=
 github.com/aws/aws-sdk-go-v2 v1.22.2 h1:lV0U8fnhAnPz8YcdmZVV60+tr6CakHzqA6P8T46ExJI=
 github.com/aws/aws-sdk-go-v2 v1.22.2/go.mod h1:Kd0OJtkW3Q0M0lUWGszapWjEvrXDzRW+D21JNsroB+c=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.0 h1:hHgLiIrTRtddC0AKcJr5s7i/hLgcpTt+q/FKxf1Zayk=
@@ -55,6 +57,9 @@ github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0q
 github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 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/fzipp/gocyclo v0.3.1/go.mod h1:DJHO6AUmbdqj2ET4Z9iArSuwWgYDRryYt2wASxc7x3E=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -81,6 +86,7 @@ github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyh
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 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/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=
diff --git a/internal/aws/ccapi/ccapi.go b/internal/aws/ccapi/ccapi.go
index fef106e4..bfc42d83 100644
--- a/internal/aws/ccapi/ccapi.go
+++ b/internal/aws/ccapi/ccapi.go
@@ -2,10 +2,20 @@ package ccapi
 
 import (
 	"context"
+	"encoding/json"
+	"fmt"
+	"sort"
 
+	"github.com/appscode/jsonpatch"
+	"github.com/aws-cloudformation/rain/cft/format"
 	"github.com/aws-cloudformation/rain/internal/aws"
 	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/aws-cloudformation/rain/internal/node"
+	"github.com/aws-cloudformation/rain/internal/s11n"
 	"github.com/aws/aws-sdk-go-v2/service/cloudcontrol"
+	"github.com/aws/aws-sdk-go-v2/service/cloudcontrol/types"
+	"github.com/google/uuid"
+	"gopkg.in/yaml.v3"
 )
 
 func getClient() *cloudcontrol.Client {
@@ -40,3 +50,299 @@ func ResourceExists(typeName string, identifier []string) bool {
 
 	return true
 }
+
+// toJsonProps converts properties in a resource node to the JSON representation
+func ToJsonProps(resource *yaml.Node) string {
+	_, props := s11n.GetMapValue(resource, "Properties")
+	if props == nil {
+		return "{}"
+	}
+	p, _ := json.Marshal(format.Jsonise(props))
+	return string(p)
+}
+
+func printProgress(p *types.ProgressEvent) string {
+	status := ""
+	if p.StatusMessage != nil {
+		status = *p.StatusMessage
+	}
+	return fmt.Sprintf("ErrorCode: %v, Identifier: %v, OperationStatus: %v, ResourceModel: %v, StatusMessage: %v",
+		p.ErrorCode, p.Identifier, p.OperationStatus, p.ResourceModel, status)
+}
+
+// CreateResource creates a resource based on the YAML node from the template,
+// and blocks until resource creation is complete.
+func CreateResource(logicalId string, resource *yaml.Node) (identifier string, model string, err error) {
+
+	clientToken := uuid.New().String()
+
+	// Intrinsics have already been resolved, so there should not
+	// be any !Refs or !GetAtts, etc
+	props := ToJsonProps(resource)
+
+	config.Debugf("CreateResource props: %v", props)
+	_, typeNode := s11n.GetMapValue(resource, "Type")
+	if typeNode == nil {
+		return identifier, model, fmt.Errorf("expected resource %v to have a Type", logicalId)
+	}
+	typeName := typeNode.Value
+	input := cloudcontrol.CreateResourceInput{
+		ClientToken:  &clientToken,
+		DesiredState: &props,
+		TypeName:     &typeName,
+	}
+	output, err := getClient().CreateResource(context.Background(), &input)
+
+	config.Debugf("CreateResource output:\n%v", printProgress(output.ProgressEvent))
+
+	if err != nil {
+		return identifier, model, err
+	}
+
+	progress := output.ProgressEvent
+	identifier, model, err = pollForCompletion(progress)
+	if err != nil {
+		return identifier, model, err
+	}
+
+	// Call GetResource to fill in the model, which for some reason is
+	// not populated on the ProgressEvent above.
+	model, err = GetResource(identifier, typeName)
+	if err != nil {
+		return identifier, model, err
+	}
+
+	return identifier, model, nil
+}
+
+// GetResource gets a resource from cloud control api
+// It returns the resource model as a string
+func GetResource(identifier string, typeName string) (string, error) {
+
+	input := &cloudcontrol.GetResourceInput{
+		Identifier: &identifier,
+		TypeName:   &typeName,
+	}
+
+	result, err := getClient().GetResource(context.Background(), input)
+
+	if err != nil {
+		return "", err
+	}
+
+	return *result.ResourceDescription.Properties, nil
+
+}
+
+// pollForCompletion checks for progress until the operation is complete or fails
+func pollForCompletion(progress *types.ProgressEvent) (string, string, error) {
+
+	var identifier string
+	var model string
+	done := false
+
+	// Poll for completion
+	for !done {
+
+		if progress.Identifier != nil {
+			identifier = *progress.Identifier
+		}
+		if progress.ResourceModel != nil {
+			model = *progress.ResourceModel
+		}
+
+		// config.Debugf("About to check OperationStatus, identifier: %v, model: %v", identifier, model)
+
+		switch progress.OperationStatus {
+		case "PENDING":
+			done = false
+		case "IN_PROGRESS":
+			done = false
+		case "SUCCESS":
+			done = true
+		case "FAILED":
+			done = true
+			msg := string(progress.ErrorCode)
+			if progress.StatusMessage != nil {
+				msg = *progress.StatusMessage
+			}
+			return identifier, model, fmt.Errorf("%v", msg)
+		case "CANCEL_IN_PROGRESS":
+			done = false
+		case "CANCEL_COMPLETE":
+			done = true
+		default:
+			config.Debugf("got unexpected status: %v", progress.OperationStatus)
+		}
+
+		if !done {
+			status, statusErr := getClient().GetResourceRequestStatus(context.Background(),
+				&cloudcontrol.GetResourceRequestStatusInput{
+					RequestToken: progress.RequestToken,
+				})
+			if statusErr != nil {
+				return identifier, model, statusErr // Is this terminal?
+				// This is not a deployment failure. Network issue?
+			}
+			// config.Debugf("status:\n%v", printProgress(status.ProgressEvent))
+
+			progress = status.ProgressEvent
+		}
+	}
+
+	return identifier, model, nil
+}
+
+// Create a json patch document based on the new props and the prior json
+// from the last version of the template (not the property model)
+func CreatePatch(props *yaml.Node, priorJson string) (string, error) {
+
+	config.Debugf("props: %v", node.ToSJson(props))
+
+	jsonProps, err := json.Marshal(format.Jsonise(props))
+	if err != nil {
+		return "", err
+	}
+
+	// This is not right. The props might be a single required property,
+	// and the priorModel will have everything, which then renders
+	// a PatchDocument with a bunch of remove operations.
+	// priorModel should be priorJson (from the old template)
+
+	operations, err := jsonpatch.CreatePatch([]byte(priorJson), jsonProps)
+	if err != nil {
+		return "", err
+	}
+
+	// Sort the operations so tests are consistent
+	sort.Slice(operations, func(i, j int) bool {
+		return operations[i].Path < operations[j].Path
+	})
+
+	patchDocument := "[\n"
+	first := true
+	for _, operation := range operations {
+		if first {
+			first = false
+		} else {
+			patchDocument += ",\n"
+		}
+		patchDocument += fmt.Sprintf("    %s", operation.Json())
+	}
+	patchDocument += "\n]"
+
+	config.Debugf("CreatePatch\n\njsonProps:\n%v\n\npriorJson:\n%v\n\nPatchDocument\nPatchDocument:\n%v",
+		string(jsonProps), priorJson, string(patchDocument))
+
+	return patchDocument, nil
+}
+
+// UpdateResource updates a resource based on the YAML node from the template,
+// and blocks until resource update is complete.
+func UpdateResource(
+	logicalId string,
+	identifier string,
+	resource *yaml.Node,
+	priorJson string) (model string, err error) {
+
+	if logicalId == "" {
+		return model, fmt.Errorf("logicalId is required for UpdateResource")
+	}
+	if identifier == "" {
+		return model, fmt.Errorf("identifier is blank for UpdateResource %v", logicalId)
+	}
+
+	clientToken := uuid.New().String()
+
+	_, typeNode := s11n.GetMapValue(resource, "Type")
+	if typeNode == nil {
+		return model, fmt.Errorf("expected resource %v to have a Type", logicalId)
+	}
+	typeName := typeNode.Value
+
+	// Intrinsics have already been resolved, so there should not
+	// be any !Refs or !GetAtts, etc
+
+	_, props := s11n.GetMapValue(resource, "Properties")
+	config.Debugf("UpdateResource %v props: %v", logicalId, node.ToSJson(props))
+
+	// Create the patch document
+	patchDocument, err := CreatePatch(props, priorJson)
+	if err != nil {
+		return model, nil
+	}
+
+	// AWS::SQS::Queue A: Failed: operation error CloudControl:
+	// UpdateResource, https response error StatusCode: 400,
+	// RequestID: 7773a459-504a-4562-a728-f0f2b6f9cd35,
+	// api error ValidationException: Invalid patch update:
+	// readOnlyProperties [/properties/QueueUrl, /properties/Arn] cannot be updated
+
+	config.Debugf("PatchDocument for %v: %v", logicalId, patchDocument)
+	input := cloudcontrol.UpdateResourceInput{
+		ClientToken:   &clientToken,
+		PatchDocument: &patchDocument,
+		TypeName:      &typeName,
+		Identifier:    &identifier,
+	}
+	output, err := getClient().UpdateResource(context.Background(), &input)
+	if err != nil {
+		return model, err
+	}
+
+	config.Debugf("UpdateResource output:\n%v", printProgress(output.ProgressEvent))
+
+	progress := output.ProgressEvent
+	_, model, err = pollForCompletion(progress)
+	if err != nil {
+		return model, err
+	}
+
+	// Call GetResource to fill in the model, which for some reason is
+	// not populated on the ProgressEvent above.
+	model, err = GetResource(identifier, typeName)
+	if err != nil {
+		return model, err
+	}
+
+	return model, nil
+}
+
+// DeleteResource deletes a resource and blocks until the operation is complete
+func DeleteResource(logicalId string, identifier string, resource *yaml.Node) error {
+	if logicalId == "" {
+		return fmt.Errorf("logicalId is required for DeleteResource")
+	}
+	if identifier == "" {
+		return fmt.Errorf("identifier is blank for DeleteResource %v", logicalId)
+	}
+
+	clientToken := uuid.New().String()
+
+	_, typeNode := s11n.GetMapValue(resource, "Type")
+	if typeNode == nil {
+		return fmt.Errorf("expected resource %v to have a Type", logicalId)
+	}
+	typeName := typeNode.Value
+
+	input := cloudcontrol.DeleteResourceInput{
+		ClientToken: &clientToken,
+		TypeName:    &typeName,
+		Identifier:  &identifier,
+	}
+	output, err := getClient().DeleteResource(context.Background(), &input)
+
+	if err != nil {
+		return err
+	}
+
+	config.Debugf("DeleteResource output:\n%v", printProgress(output.ProgressEvent))
+
+	progress := output.ProgressEvent
+	_, _, err = pollForCompletion(progress)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/internal/aws/s3/mock.go b/internal/aws/s3/mock.go
index 543c36a3..1ae8c640 100644
--- a/internal/aws/s3/mock.go
+++ b/internal/aws/s3/mock.go
@@ -33,7 +33,7 @@ func Upload(bucketName string, content []byte) (string, error) {
 	if !isBucketExists {
 		return "", fmt.Errorf("bucket does not exist: '%s'", bucketName)
 	}
-	return filepath.Join ( BucketKeyPrefix, fmt.Sprintf("%x", sha256.Sum256(content)) ), nil
+	return filepath.Join(BucketKeyPrefix, fmt.Sprintf("%x", sha256.Sum256(content))), nil
 }
 
 // RainBucket returns the name of the rain deployment bucket in the current region
@@ -66,3 +66,15 @@ func RainBucket(forceCreation bool) string {
 func BucketHasContents(bucketName string) (bool, error) {
 	return true, nil
 }
+
+func PutObject(bucketName string, key string, body []byte) error {
+	return nil
+}
+
+func GetObject(bucketName string, key string) ([]byte, error) {
+	return make([]byte, 0), nil
+}
+
+func DeleteObject(bucketName string, key string) error {
+	return nil
+}
diff --git a/internal/aws/s3/s3.go b/internal/aws/s3/s3.go
index 413e072e..01b36983 100644
--- a/internal/aws/s3/s3.go
+++ b/internal/aws/s3/s3.go
@@ -8,6 +8,7 @@ import (
 	"crypto/sha256"
 	"errors"
 	"fmt"
+	"io"
 	"path/filepath"
 
 	"github.com/aws/aws-sdk-go-v2/service/s3"
@@ -22,7 +23,7 @@ import (
 )
 
 var BucketName = ""
-var BucketKeyPrefix = "" 
+var BucketKeyPrefix = ""
 
 func getClient() *s3.Client {
 	return s3.NewFromConfig(aws.Config())
@@ -150,7 +151,7 @@ func Upload(bucketName string, content []byte) (string, error) {
 		return "", fmt.Errorf("bucket does not exist: '%s'", bucketName)
 	}
 
-	key := filepath.Join ( BucketKeyPrefix, fmt.Sprintf("%x", sha256.Sum256(content)) )
+	key := filepath.Join(BucketKeyPrefix, fmt.Sprintf("%x", sha256.Sum256(content)))
 
 	_, err := getClient().PutObject(context.Background(), &s3.PutObjectInput{
 		Bucket: ptr.String(bucketName),
@@ -200,3 +201,41 @@ func RainBucket(forceCreation bool) string {
 
 	return bucketName
 }
+
+// GetObject gets an object by key from an S3 bucket
+func GetObject(bucketName string, key string) ([]byte, error) {
+	result, err := getClient().GetObject(context.Background(),
+		&s3.GetObjectInput{
+			Bucket: &bucketName,
+			Key:    &key,
+		})
+	if err != nil {
+		return nil, err
+	}
+	body, err := io.ReadAll(result.Body)
+	if err != nil {
+		return nil, err
+	}
+	return body, nil
+}
+
+// PutObject puts an object into a bucket
+func PutObject(bucketName string, key string, body []byte) error {
+	_, err := getClient().PutObject(context.Background(),
+		&s3.PutObjectInput{
+			Bucket: &bucketName,
+			Key:    &key,
+			Body:   bytes.NewReader(body),
+		})
+	return err
+}
+
+// DeleteObject deletes an object from a bucket
+func DeleteObject(bucketName string, key string) error {
+	_, err := getClient().DeleteObject(context.Background(),
+		&s3.DeleteObjectInput{
+			Bucket: &bucketName,
+			Key:    &key,
+		})
+	return err
+}
diff --git a/internal/cmd/ccdeploy/README.md b/internal/cmd/ccdeploy/README.md
new file mode 100644
index 00000000..070f205c
--- /dev/null
+++ b/internal/cmd/ccdeploy/README.md
@@ -0,0 +1,139 @@
+# ccdeploy
+
+The `rain ccdeploy` 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
+stored by Rain in the same S3 bucket that is used for assets.
+
+This is a highly experimental feature that exists mainly as a prototype for
+what a client-side provisioning engine might look like. *Do not* use this for
+production workloads. (Seriously)
+
+Only resources that have been fully migrated to the CloudFormation registry can
+be provisioned with this command. It is important to note that resource
+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`, 
+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
+of the AWS CloudFormation
+Registry](https://aws.amazon.com/blogs/devops/cloudformation-coverage/)
+
+If you want to see if a given CloudFormation resource is on the new registry
+model or not, check if the provisioning type is either Fully Mutable or
+Immutable by invoking the DescribeType API and inspecting the ProvisioningType
+response element.
+
+Here is a sample CLI command that gets a description for the
+AWS::Lambda::Function resource, which is on the new registry model.
+
+```
+sh $ aws cloudformation describe-type --type RESOURCE \ --type-name AWS::Lambda::Function | grep ProvisioningType
+
+   "ProvisioningType": "FULLY_MUTABLE", 
+```
+
+The difference between FULLY\_MUTABLE and IMMUTABLE is the presence of the
+Update handler. FULLY\_MUTABLE types includes an update handler to process
+updates to the type during stack update operations. Whereas, IMMUTABLE types do
+not include an update handler, so the type can’t be updated and must instead be
+replaced during stack update operations. Legacy resource types will be
+NON\_PROVISIONABLE.
+
+## Why would I want to use this?
+
+Again, for production workloads, you shouldn't. But the one big benefit is that you 
+have access to the resource state, which is described in detail below. You could, 
+in theory, modify the state to deal with unexpected deployment failures, or to 
+remediate complex drift situations. Template deployment might be slightly faster, since you 
+won't wait for the CloudFormation backend to push your stack through the workflow, but since 
+CloudFormation uses the same resource providers, the difference will not be huge.
+
+Another good reason is that you are curious about how CCAPI works, and you are
+interested in learning about all the really hard things a template provisioning
+engine has to do. Let us know if you want to dive in and contribute. 
+The best way to learn is by doing!
+
+## State management
+
+State cannot be managed based on the template alone, due to the fact that
+primary identifiers are not always required (and often disouraged). For
+example, the following template deploys an S3 bucket:
+
+```yaml 
+Resources: 
+    MyBucket: 
+        Type: AWS::S3::Bucket 
+```
+
+The physical name of the bucket was not specified, and since two different
+templates could both have an S3 bucket with the logical ID "MyBucket", there is
+no way to tell from looking at the template alone what bucket it corresponds to
+in your account.
+
+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
+format as the source CloudFormation template. Extra sections are added to the
+file to associate state with the deployed resources.
+
+```yaml 
+State: 
+  LastWriteTime: ...
+  ResourceModels:
+    MyResource:
+      Identifier:
+      Model:
+        ...
+```
+
+Each deployment has its own state file in the rain artifacts bucket in the
+region where you are deploying. If a user tries a deploymemnt while there is a
+locked state file, the command gives an error message with instructions on how
+to remediate the issue. Often times, this will result from a deployment that
+failed halfway through.
+
+```
+rain-artifacts-0123456789012-us-east-1/ 
+    deployments/ 
+        name1.yaml
+        name2.yaml
+```
+
+Drift detection can be run on the state file to inspect the actual resource
+properties and compare them to the stored state.
+
+## Usage
+
+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
+```
+
+To remove resources deployed with `ccdeploy`, use the `ccrm` command:
+
+```sh
+$ rain ccrm my-deployment-name
+```
+
+## Unsupported features
+
+Since this is a prototype, some features are not yet supported:
+
+- Instrinsic functions (`Fn::*`, `GetAtt`, `Sub`)
+- Parameters
+- Tags
+- Retention policies
+- Probably more stuff that is totally necessary for production use
+
+
+
+
+
diff --git a/internal/cmd/ccdeploy/ccdeploy.go b/internal/cmd/ccdeploy/ccdeploy.go
new file mode 100644
index 00000000..4a384ea4
--- /dev/null
+++ b/internal/cmd/ccdeploy/ccdeploy.go
@@ -0,0 +1,138 @@
+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/deployment.go b/internal/cmd/ccdeploy/deployment.go
new file mode 100644
index 00000000..41a4be3c
--- /dev/null
+++ b/internal/cmd/ccdeploy/deployment.go
@@ -0,0 +1,421 @@
+package ccdeploy
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/aws-cloudformation/rain/cft"
+	"github.com/aws-cloudformation/rain/cft/diff"
+	"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/node"
+	"github.com/aws-cloudformation/rain/internal/s11n"
+	"gopkg.in/yaml.v3"
+)
+
+// 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]
+	_, resources := s11n.GetMapValue(rootMap, "Resources")
+	if resources == nil {
+		panic("Expected to find a Resources section in the template")
+	}
+	for i, r := range resources.Content {
+		if i%2 != 0 {
+			continue
+		}
+		if logicalId == r.Value {
+			resource := resources.Content[i+1]
+			return resource, nil
+		}
+	}
+	return nil, fmt.Errorf("could not find Resource %v", logicalId)
+}
+
+// deployResource calls the Cloud Control API to deploy the resource
+func deployResource(resource *Resource) {
+	config.Debugf("Deploying %v...", resource)
+
+	resource.State = Deploying
+
+	// 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)
+	}
+
+	switch resource.Action {
+	case diff.Create:
+
+		// Get the properties and call ccapi
+		var identifier string
+		var model string
+		identifier, model, err = ccapi.CreateResource(resource.Name, resolvedNode)
+		if err != nil {
+			config.Debugf("deployResource create failed: %v", err)
+			resource.State = Failed
+			resource.Message = fmt.Sprintf("%v", err)
+		} else {
+			resource.State = Deployed
+			resource.Message = "Success"
+			resource.Identifier = identifier
+			resource.Model = model
+		}
+	case diff.Update:
+
+		// // First get the current state of the resource
+		// priorModel, err := ccapi.GetResource(resource.Identifier, resource.Type)
+		// if err != nil {
+		// 	config.Debugf("deployResource update get prior failed: %v", err)
+		// 	resource.State = Failed
+		// 	resource.Message = fmt.Sprintf("%v", err)
+		// }
+		//
+		// We would need that at an earlier state for drift detection
+
+		priorJson := resource.PriorJson
+
+		var model string
+		model, err = ccapi.UpdateResource(resource.Name,
+			resource.Identifier, resolvedNode, priorJson)
+		if err != nil {
+			config.Debugf("deployResource update failed: %v", err)
+			resource.State = Failed
+			resource.Message = fmt.Sprintf("%v", err)
+		} else {
+			config.Debugf("deployResource update succeeded: %v", model)
+			resource.State = Deployed
+			resource.Message = "Success"
+			resource.Model = model
+		}
+
+	case diff.Delete:
+
+		err = ccapi.DeleteResource(resource.Name, resource.Identifier, resolvedNode)
+		if err != nil {
+			config.Debugf("deployResource delete failed: %v", err)
+			resource.State = Failed
+			resource.Message = fmt.Sprintf("%v", err)
+		} else {
+			resource.State = Deployed
+			resource.Message = "Success"
+		}
+
+	default:
+		// None means this is an update with no change to the model
+		config.Debugf("deployResource not deploying unchanged %v", resource.Name)
+		resource.State = Deployed
+		resource.Message = "Success"
+	}
+
+}
+
+// ready returns true if the resource has no undeployed dependencies,
+// TODO: unless the Action is Delete, in which case it returns true if it has
+// no undeleted dependents
+func ready(resource *Resource, g *graph.Graph) bool {
+
+	node := graph.Node{Name: resource.Name, Type: "Resources"}
+	var deps []graph.Node
+	if resource.Action == diff.Delete {
+		deps = g.GetReverse(node)
+	} else {
+		deps = g.Get(node)
+	}
+
+	// Iterate over each of this resource's dependencies (or dependents for deletes)
+	for _, dep := range deps {
+
+		if dep.Type != "Resources" {
+			continue
+		}
+
+		depr := resMap[dep.Name]
+
+		// If the dependency is not deployed, terminate
+		if depr.State != Deployed {
+			return false
+		}
+
+		// Recurse on each dependency
+		if !ready(depr, g) {
+			return false
+		}
+	}
+
+	// If we get here, the resource can be deployed
+	return true
+}
+
+// DeploymentResults captures everything that happened as a result of deployment
+type DeploymentResults struct {
+	Succeeded bool
+	State     cft.Template
+	Resources map[string]*Resource
+}
+
+// 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
+// for any dependent resources that are not marked for deletion
+func canDelete(resource *Resource, g *graph.Graph, resourceMap map[string]*Resource) bool {
+
+	dependents := g.GetReverse(graph.Node{Name: resource.Name, Type: "Resources"})
+	for _, n := range dependents {
+		depResource, ok := resourceMap[n.Name]
+		if !ok {
+			// This should not happen
+			panic(fmt.Errorf("did not find %v in resourceMap", n.Name))
+		}
+		if depResource.Action != diff.Delete {
+			return false
+		}
+		// Recurse
+		if !canDelete(depResource, g, resourceMap) {
+			return false
+		}
+	}
+	return true
+}
+
+// verifyDeletes returns an error if any of the resources have dependents in the graph that are
+// not also being deleted. All resources passed in must have Action = Delete
+func verifyDeletes(resources []*Resource, g *graph.Graph, resourceMap map[string]*Resource) error {
+
+	/*
+				Down is depends on
+
+				         A
+				        / \
+				       B   C
+		                    \
+		                     D
+
+				If we are deleting C or D, but not A, fail.
+	*/
+
+	for _, resource := range resources {
+		if resource.Action != diff.Delete {
+			return fmt.Errorf("cannot verify deletes on resource %v that is not being deleted", resource.Name)
+		}
+		if !canDelete(resource, g, resourceMap) {
+			return fmt.Errorf("resource %v has dependent resources that will not be deleted", resource.Name)
+		}
+	}
+
+	return nil
+}
+
+// deployResources deploys a set of resources - either all the deletes, or
+// all of the creates and updates. Deletes are handled in reverse dependency order.
+func deployResources(resources []*Resource, results *DeploymentResults, g *graph.Graph) error {
+
+	numResources := len(resources)
+	numDone := 0
+	failed := false
+
+	config.Debugf("About to deploy %v resources", numResources)
+
+	// TODO - Instead of re-evaluating readiness for each resource in a loop,
+	// it might be better to create a deployment plan, with a pre-determined
+	// order of operations, and then simply iterate over that plan.
+
+	for numDone < numResources {
+
+		// config.Debugf("Starting an iteration over resources (%v/%v done)",
+		// 	numDone, numResources)
+
+		numDone = 0
+
+		for _, r := range resources {
+
+			if r.State == Deployed || r.State == Canceled {
+				numDone += 1
+				continue
+			}
+
+			if r.State == Deploying {
+				continue
+			}
+
+			if r.State == Failed {
+				// This will prevent any additional resources from deploying,
+				// but any that had already started creation will complete
+				failed = true
+				numDone += 1
+				continue
+			}
+
+			if !failed {
+				// Recurse dependencies to see if it's ok to deploy this one now
+				if ready(r, g) {
+					// Start a goroutine to do the actual deployment
+					go deployResource(r)
+				}
+			} else {
+				if r.State == Waiting {
+					r.State = Canceled
+				}
+			}
+		}
+
+		// for _, r := range resources {
+		// 	config.Debugf("%v", r)
+		// }
+
+		// Give deployment routines time to finish
+		// TODO: We could be smarter about this with channels...
+		time.Sleep(time.Second * 1)
+	}
+
+	for _, r := range resources {
+		results.Resources[r.Name] = r
+	}
+
+	if failed {
+		results.Succeeded = false
+	}
+
+	return nil
+}
+
+// deployTemplate deloys the CloudFormation template using the Cloud Control API.
+// A failed deployment will result in DeploymentResults.Succeeded = false.
+// A non-nil error is returned when something unexpected caused a failure
+// not related to actually deploying resources, like an invalid template.
+func DeployTemplate(template cft.Template) (*DeploymentResults, error) {
+
+	results := &DeploymentResults{
+		Succeeded: true,
+		State:     cft.Template{},
+		Resources: make(map[string]*Resource),
+	}
+
+	var err error
+
+	results.State.Node = node.Clone(template.Node)
+
+	// Create a dependency graph of the template
+	g := graph.New(template)
+	nodes := g.Nodes()
+
+	/*
+		Downwards is "depends on"
+
+		   A   E   F
+		  / \   \\
+		 B   C   GH
+			  \
+			   D
+
+		B, D, G, and H can all be deployed at the same time.
+
+		We work our way up from the bottom, deploying resources concurrently
+		as soon as they have no more undeployed dependencies.
+
+		Deletes have to go in the reverse order.
+		A depends on B, if I'm deleting both, A has to be deleted first.
+		Verify that we are not deleting anything depended on by a live resource.
+		Fail before deploying anything if a delete would remove a dependency.
+	*/
+
+	// Wrap Nodes in a Resource to add state
+	deletes := make([]*Resource, 0)
+	createsUpdates := make([]*Resource, 0)
+	resourceMap := make(map[string]*Resource)
+	for _, n := range nodes {
+		if n.Type == "Resources" {
+			y, err := getTemplateResource(template, n.Name)
+			if err != nil {
+				panic(fmt.Sprintf("%v not found in Resources", n.Name))
+			}
+
+			_, typeNode := s11n.GetMapValue(y, "Type")
+			if typeNode == nil {
+				return nil, fmt.Errorf("expected resource %v to have a Type", n.Name)
+			}
+			typeName := typeNode.Value
+
+			// Determine if this is a create, update, or delete
+			var action diff.ActionType
+			var ident string
+			var model string
+			var priorJson string
+			_, stateNode := s11n.GetMapValue(y, "State")
+			if stateNode == nil {
+				// Assume this is a new deployment
+				action = diff.Create
+			} else {
+				for i, s := range stateNode.Content {
+					if i%2 == 0 {
+						if s.Value == "Action" {
+							a := stateNode.Content[i+1].Value
+							action = diff.ActionType(a)
+							isValid := false
+							switch action {
+							case diff.Create, diff.Update, diff.Delete, diff.None:
+								isValid = true
+							}
+							if !isValid {
+								return nil, fmt.Errorf("invalid Action %v for %v", a, n.Name)
+							}
+						} else if s.Value == "Identifier" {
+							ident = stateNode.Content[i+1].Value
+						} else if s.Value == "ResourceModel" {
+							model = stateNode.Content[i+1].Value
+						} else if s.Value == "PriorJson" {
+							priorJson = stateNode.Content[i+1].Value
+						} else {
+							config.Debugf("Unexpected State key %v", s.Value)
+						}
+					}
+				}
+			}
+
+			r := NewResource(n.Name, typeName, Waiting, y)
+			r.Action = action
+			r.Identifier = ident
+			r.Model = model         // This will get overwritten. Do we need it here?
+			r.PriorJson = priorJson // We need this for ccapi update
+
+			config.Debugf("deployment set r.Model to %v", r.Model)
+
+			if r.Action == diff.Delete {
+				deletes = append(deletes, r)
+			} else {
+				createsUpdates = append(createsUpdates, r)
+			}
+			resourceMap[r.Name] = r
+		}
+	}
+
+	// Check to make sure there are no deletes with dependents that are not being deleted
+	if err = verifyDeletes(deletes, &g, resourceMap); err != nil {
+		return nil, fmt.Errorf("unable to deploy, deleted resources have one or more dependents: %v", err)
+	}
+
+	// Check to make sure there are no circular dependencies
+	// TODO - Does the graph do this for us already?
+
+	// Delete everything that needs to be deleted first
+	err = deployResources(deletes, results, &g)
+	if err != nil {
+		return nil, err
+	}
+	if !results.Succeeded {
+		return nil, fmt.Errorf("unable to delete resources: %v", err)
+	}
+
+	// Deploy the rest of the resources
+	err = deployResources(createsUpdates, results, &g)
+	if err != nil {
+		return nil, err
+	}
+
+	return results, nil
+
+}
diff --git a/internal/cmd/ccdeploy/resolve.go b/internal/cmd/ccdeploy/resolve.go
new file mode 100644
index 00000000..37d9e319
--- /dev/null
+++ b/internal/cmd/ccdeploy/resolve.go
@@ -0,0 +1,34 @@
+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/ccdeploy/resource.go b/internal/cmd/ccdeploy/resource.go
new file mode 100644
index 00000000..be250deb
--- /dev/null
+++ b/internal/cmd/ccdeploy/resource.go
@@ -0,0 +1,61 @@
+package ccdeploy
+
+import (
+	"fmt"
+
+	"github.com/aws-cloudformation/rain/cft/diff"
+	"gopkg.in/yaml.v3"
+)
+
+type ResourceState int
+
+const (
+	Waiting ResourceState = iota
+	Deploying
+	Failed
+	Deployed
+	Canceled
+)
+
+type Resource struct {
+	Name       string
+	Type       string
+	Node       *yaml.Node
+	State      ResourceState
+	Message    string
+	Identifier string
+	Model      string
+	Action     diff.ActionType
+	PriorJson  string
+	// TODO - Add elapsed time
+}
+
+func (r Resource) String() string {
+	state := ""
+	switch r.State {
+	case Waiting:
+		state = "Waiting"
+	case Deploying:
+		state = "Deploying"
+	case Deployed:
+		state = "Deployed"
+	case Failed:
+		state = "Failed"
+	case Canceled:
+		state = "Canceled"
+	}
+	if r.State == Failed {
+		return fmt.Sprintf("%s %s: %s: %v", r.Type, r.Name, state, r.Message)
+	} else {
+		return fmt.Sprintf("%s %s: %s (%v)", r.Type, r.Name, state, r.Action)
+	}
+}
+
+// NewResource creates a new Resource and adds it to the map
+func NewResource(name string,
+	resourceType string, state ResourceState, node *yaml.Node) *Resource {
+
+	r := &Resource{Name: name, Type: resourceType, State: state, Node: node}
+	resMap[name] = r // TODO - This is global, do we really need it?
+	return r
+}
diff --git a/internal/cmd/ccdeploy/state.go b/internal/cmd/ccdeploy/state.go
new file mode 100644
index 00000000..6ad7c69d
--- /dev/null
+++ b/internal/cmd/ccdeploy/state.go
@@ -0,0 +1,221 @@
+package ccdeploy
+
+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/parse"
+	"github.com/aws-cloudformation/rain/internal/aws/s3"
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/aws-cloudformation/rain/internal/node"
+	"github.com/aws-cloudformation/rain/internal/s11n"
+	"github.com/aws/aws-sdk-go-v2/service/s3/types"
+	"github.com/google/uuid"
+	"gopkg.in/yaml.v3"
+)
+
+const STATE_DIR string = "deployments"
+const FILE_PATH string = "FilePath"
+
+type StateResult struct {
+	StateFile cft.Template
+	Lock      string
+	IsUpdate  bool
+}
+
+// addCommon adds common elements to the state file
+// If the elements already exist, they are replaced
+func addCommon(stateMap *yaml.Node, absPath string) {
+	// Record the absolute path (helps figure out who/where the template came from)
+	if _, fp := s11n.GetMapValue(stateMap, FILE_PATH); fp != nil {
+		config.Debugf("addCommon overwriting")
+		fp.Value = absPath
+	} else {
+		config.Debugf("addCommon adding")
+		node.Add(stateMap, FILE_PATH, absPath)
+	}
+}
+
+// checkState looks for an existing state file.
+//
+// If one does not exist, it is created.
+//
+// If one exists and there is a lock, an error is returned unless this process
+// owns the lock.  If priorLock matches, then we are in the middle of an update
+// and for some reason we needed to re-check state
+//
+// If one exists and there is no lock, this is an update.
+// Save the state file back with a lock that we own
+func checkState(
+	name string,
+	template cft.Template,
+	bucketName string,
+	priorLock string,
+	absPath string) (*StateResult, error) {
+
+	key := fmt.Sprintf("%v/%v.yaml", STATE_DIR, name) // deployments/name
+	var state cft.Template
+
+	result := &StateResult{}
+
+	// TODO: Handle race conditions (which seem unlikely but who knows...)
+	// Double check before writing a lock that someone else didn't grab it?
+	// Want to avoid using another service like DDB for this. Keep it simple.
+
+	obj, err := s3.GetObject(bucketName, key)
+	if err != nil {
+
+		// Make sure it's a NotFound error
+		var nf *types.NoSuchKey
+		if !errors.As(err, &nf) {
+			return nil, err
+		}
+
+		config.Debugf("No state file found, creating")
+
+		// This is a create operation. Create a state file and lock it.
+		lock := uuid.New().String()
+		config.Debugf("Creating new state file with lock %v", lock)
+		state = cft.Template{Node: node.Clone(template.Node)}
+		result.StateFile = state
+		result.Lock = lock
+		result.IsUpdate = false
+
+		// Edit the state template to add a new top level "State" section
+		stateMap := cft.AppendStateMap(state)
+
+		// Lock it
+		node.Add(stateMap, "Lock", lock)
+
+		// Add common elements
+		addCommon(stateMap, absPath)
+
+		// Write the state file to the bucket
+		str := format.String(state, format.Options{JSON: false, Unsorted: false})
+		err := s3.PutObject(bucketName, key, []byte(str))
+		if err != nil {
+			return nil, fmt.Errorf("unable to write state to bucket: %v", err)
+		}
+
+		config.Debugf("State file created with lock: %v", lock)
+
+	} else {
+		// The state file exists. Inspect it to see if it's locked
+
+		config.Debugf("Found existing state file")
+
+		state, err := parse.String(string(obj))
+		if err != nil {
+			return nil, fmt.Errorf("unable to parse state file: %v", err)
+		}
+
+		_, stateMap := s11n.GetMapValue(state.Node.Content[0], "State")
+		if stateMap == nil {
+			return nil, fmt.Errorf("did not find State in state file")
+		}
+
+		result.StateFile = state
+		result.IsUpdate = true
+		lock := ""
+		for i, s := range stateMap.Content {
+			if s.Kind == yaml.ScalarNode && s.Value == "Lock" {
+				lock = stateMap.Content[i+1].Value
+			}
+		}
+		result.Lock = lock
+
+		if lock != "" {
+			return nil, fmt.Errorf("lock: %v", lock)
+		}
+
+		// 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)
+
+		// Add common elements
+		addCommon(stateMap, absPath)
+
+		str := format.String(state, format.Options{JSON: false, Unsorted: false})
+		err = s3.PutObject(bucketName, key, []byte(str))
+		if err != nil {
+			return nil, fmt.Errorf("unable to write updated state file to bucket: %v", err)
+		}
+		config.Debugf("State file updated with lock: %v", lock)
+	}
+
+	return result, nil
+}
+
+// writeState writes updated state to the state file in S3 and unlocks it
+// The state passed in should be the original template, since we will
+// overwrite state with current values.
+func writeState(
+	state cft.Template,
+	results *DeploymentResults,
+	bucketName string,
+	name string,
+	absPath string) error {
+
+	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")
+
+	// 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
+		}
+		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)
+		}
+
+		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...)
+	}
+
+	str := format.String(state, format.Options{JSON: false, Unsorted: false})
+	config.Debugf("About to write state file:\n%v", str)
+	key := fmt.Sprintf("%v/%v.yaml", STATE_DIR, name) // deployments/name
+	err := s3.PutObject(bucketName, key, []byte(str))
+	if err != nil {
+		return fmt.Errorf("unable to write unlocked state file to bucket: %v", err)
+	}
+
+	return nil
+}
diff --git a/internal/cmd/ccdeploy/update.go b/internal/cmd/ccdeploy/update.go
new file mode 100644
index 00000000..dc6056b0
--- /dev/null
+++ b/internal/cmd/ccdeploy/update.go
@@ -0,0 +1,259 @@
+package ccdeploy
+
+import (
+	"fmt"
+
+	"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/internal/aws/ccapi"
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/aws-cloudformation/rain/internal/node"
+	"github.com/aws-cloudformation/rain/internal/s11n"
+	"gopkg.in/yaml.v3"
+)
+
+// update compares the template with the current state and returns a
+// cloned template annotated with operations to perform on each resource
+func update(stateTemplate cft.Template, template cft.Template) (cft.Template, error) {
+
+	// Create a diff between the current state and template
+	d := diff.New(stateTemplate, template)
+	config.Debugf("update diff:\nMode:%v\n%v", d.Mode(), d.Format(true))
+
+	// Each modified resource needs to be tagged with create-update-delete-none,
+	// so that deployResource knows which action to take.
+	// We don't need a deep diff, only to identify what resources have changed.
+
+	// In the template, write a node to the resource's State
+	/*
+		   Resources:
+			 MyBucket:
+				Type: AWS::S3::Bucket
+			 State:
+				Action: Create or Update or Delete or None
+				Identifier: ...
+				ResourceModel: (Might need this for drift detection)
+				PriorJson: (We need this for ccapi update)
+
+	*/
+
+	// Figure out what we're doing with each resource (create, update, delete, nothing)
+	actions := diff.GetResourceActions(d)
+
+	// Iterate through the state resources and check the diff
+	stateRootMap := stateTemplate.Node.Content[0]
+	_, stateResourceMap := s11n.GetMapValue(stateRootMap, "Resources")
+	if stateResourceMap == nil {
+		panic("Expected to find a Resources section in the state template")
+	}
+	_, stateStateMap := s11n.GetMapValue(stateRootMap, "State")
+	if stateStateMap == nil {
+		panic("Expected to find a State section in the state template")
+	}
+	_, stateResourceModels := s11n.GetMapValue(stateStateMap, "ResourceModels")
+	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 {
+		if i%2 == 0 {
+			_, identifier := s11n.GetMapValue(stateResourceModels.Content[i+1], "Identifier")
+			if identifier != nil {
+				identifiers[v.Value] = identifier.Value
+			}
+			_, model := s11n.GetMapValue(stateResourceModels.Content[i+1], "Model")
+			if model != nil {
+				models[v.Value] = node.Clone(model)
+			}
+		}
+	}
+	config.Debugf("identifiers: %v", identifiers)
+
+	// Make a copy of the template so the caller still has the original as the user wrote it
+	newTemplate := cft.Template{}
+	newTemplate.Node = node.Clone(template.Node)
+
+	// Get a reference to the resources in the new template
+	newRootMap := newTemplate.Node.Content[0]
+	_, newResourceMap := s11n.GetMapValue(newRootMap, "Resources")
+	if newResourceMap == nil {
+		panic("Expected to find a Resources section in the new template")
+	}
+
+	stateResources := make(map[string]*yaml.Node, 0)
+	newResources := make(map[string]*yaml.Node, 0)
+	resourceActionStates := make(map[string]*yaml.Node) // "State" mapping node
+
+	for i, r := range stateResourceMap.Content {
+		if i%2 == 0 {
+			name := r.Value
+			stateResources[name] = stateResourceMap.Content[i+1]
+		}
+	}
+	for i, r := range newResourceMap.Content {
+		if i%2 == 0 {
+			name := r.Value
+			newResources[name] = newResourceMap.Content[i+1]
+			resourceActionStates[name] = node.AddMap(newResources[name], "State")
+		}
+	}
+
+	// Iterate over the diff and add actions to the output file
+	for k, v := range actions {
+		rmap, ok := resourceActionStates[k]
+		if !ok {
+			// Anything missing from the new template is a removed resource to be deleted
+			// Clone the resource from the state template and re-add it to the
+			// new template with Action: Delete
+			if v != diff.Delete {
+				return stateTemplate, fmt.Errorf("unexpected missing resource %v has Action %v", k, v)
+			}
+			newResourceMap.Content = append(newResourceMap.Content,
+				&yaml.Node{Kind: yaml.ScalarNode, Value: k})
+			cloned := node.Clone(stateResources[k])
+			clonedStateMap := node.AddMap(cloned, "State")
+			node.Add(clonedStateMap, "Action", string(v))
+			// Add the identifier so we know what to delete
+			if identifier, ok := identifiers[k]; ok {
+				node.Add(clonedStateMap, "Identifier", identifier)
+			}
+			newResourceMap.Content = append(newResourceMap.Content, cloned)
+		} 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)
+			}
+			// 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 {
+				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])
+				config.Debugf("update setting priorProps: %v", priorProps)
+				node.Add(rmap, "PriorJson", priorProps)
+			}
+		}
+	}
+
+	/*
+		    Drift remediation.
+
+			Several scenarios are possible here, based on various versions of the resource model:
+
+			1. The current template being submitted.
+			2. The prior template, as recorded in the state file.
+			3. The actual state of the resource.
+
+			For example, the current template has
+
+			MyQueue:
+			  Type: AWS::SQS::Queue
+			  Properties:
+			  	DelaySeconds: 1
+
+			The new template has
+
+			MyQueue:
+			  Type: AWS::SQS::Queue
+			  Properties:
+			  	DelaySeconds: 2
+
+			And the current state is
+
+			Model:
+				Arn: arn:aws:sqs:us-east-1:755952356119:ccdeploy-a
+				DelaySeconds: 3
+				MaximumMessageSize: 262144
+				MessageRetentionPeriod: 345600
+				QueueName: ccdeploy-a
+				QueueUrl: https://sqs.us-east-1.amazonaws.com/755952356119/ccdeploy-a
+				ReceiveMessageWaitTimeSeconds: 0
+				SqsManagedSseEnabled: true
+				VisibilityTimeout: 30
+
+			The message to the user would be:
+
+				Resource MyQueue has drifted from the prior known state
+				and does not match the template you are deploying:
+
+				Current actual state:
+					DelaySeconds: 3
+				Prior recorded state:
+					DelaySeconds: 1
+				New template desired state:
+				    DelaySeconds: 2
+
+				What would you like to do?
+				1) Stop the deployment.
+				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?
+
+				For CICD, we need to be able to specify the choice with args.
+
+			How much do we care about this? Should this be default behavior, or should
+			we add a flag like --warn-on-drift?
+
+			This makes our diff generation more complicated, since there are two
+			different diffs to consider.
+
+
+	*/
+
+	return newTemplate, nil
+}
+
+// summarizeChanges prints out a summary of the changes that will be made
+// when the template is deployed. This function expects the State property
+// to be populated on each resource.
+func summarizeChanges(changes cft.Template) {
+
+	d := format.String(changes, format.Options{
+		JSON:     false,
+		Unsorted: false,
+	})
+	config.Debugf("change template: %v", d)
+
+	rootMap := changes.Node.Content[0]
+	_, resourceMap := s11n.GetMapValue(rootMap, "Resources")
+	if resourceMap == nil {
+		panic("expected Resources")
+	}
+	fmt.Println("Summary of deployment changes:")
+	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))
+			}
+			_, 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
+					}
+				}
+			}
+			fmt.Printf("%v\t%v\t%v\t%v\n", name, t, action, ident)
+		}
+	}
+
+}
diff --git a/internal/cmd/ccrm/ccrm.go b/internal/cmd/ccrm/ccrm.go
new file mode 100644
index 00000000..04aef290
--- /dev/null
+++ b/internal/cmd/ccrm/ccrm.go
@@ -0,0 +1,143 @@
+package ccrm
+
+import (
+	"fmt"
+
+	"github.com/aws-cloudformation/rain/cft"
+	"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"
+	"github.com/aws-cloudformation/rain/internal/node"
+	"github.com/aws-cloudformation/rain/internal/s11n"
+	"github.com/spf13/cobra"
+	"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.",
+	Args:                  cobra.ExactArgs(1),
+	Aliases:               []string{"ccremove", "ccdel", "ccdelete"},
+	DisableFlagsInUseLine: true,
+	Run: func(cmd *cobra.Command, args []string) {
+		name := args[0]
+
+		spinner.Push("Fetching deployment status")
+		key := fmt.Sprintf("%v/%v.yaml", ccdeploy.STATE_DIR, name) // deployments/name
+		var state cft.Template
+
+		// Call RainBucket for side-effects in case we want to force bucket creation
+		bucketName := s3.RainBucket(true)
+
+		obj, err := s3.GetObject(bucketName, key)
+		if err != nil {
+			panic(err)
+		}
+
+		state, err = parse.String(string(obj))
+		if err != nil {
+			panic(fmt.Errorf("unable to parse state file: %v", err))
+		}
+
+		_, stateMap := s11n.GetMapValue(state.Node.Content[0], "State")
+		if stateMap == nil {
+			panic(fmt.Errorf("did not find State in state file"))
+		}
+
+		lock := ""
+		for i, s := range stateMap.Content {
+			if s.Kind == yaml.ScalarNode && s.Value == "Lock" {
+				lock = stateMap.Content[i+1].Value
+			}
+		}
+
+		spinner.Pop()
+
+		if lock != "" {
+			msg := "Unable to remove deployment, found a locked state file"
+			panic(fmt.Errorf("%v:\ns3://%v/%v (%v)", msg, bucketName, key, lock))
+		}
+
+		if !yes {
+			if !console.Confirm(false, "Are you sure you want to delete this deployment?") {
+				//lint:ignore ST1005 NA
+				panic(fmt.Errorf("Deployment removal cancelled: '%s'", name))
+			}
+		}
+
+		spinner.StartTimer(fmt.Sprintf("Removing deployment %v", name))
+
+		// Mark each resource with the delete action
+		template := cft.Template{Node: node.Clone(state.Node)}
+		rootMap := template.Node.Content[0]
+
+		_, stateResourceModels := s11n.GetMapValue(stateMap, "ResourceModels")
+		if stateResourceModels == nil {
+			panic("Expected to find State.ResourceModels in the state template")
+		}
+		identifiers := make(map[string]string, 0)
+		for i, v := range stateResourceModels.Content {
+			if i%2 == 0 {
+				_, identifier := s11n.GetMapValue(stateResourceModels.Content[i+1], "Identifier")
+				if identifier != nil {
+					identifiers[v.Value] = identifier.Value
+				}
+			}
+		}
+		config.Debugf("identifiers: %v", identifiers)
+
+		_, resourceMap := s11n.GetMapValue(rootMap, "Resources")
+		for i, resource := range resourceMap.Content {
+			if i%2 == 0 {
+				if identifier, ok := identifiers[resource.Value]; !ok {
+					panic(fmt.Errorf("unable to find identifier for %v", resource.Value))
+				} else {
+					r := resourceMap.Content[i+1]
+					s := node.AddMap(r, "State")
+					node.Add(s, "Action", "Delete")
+					node.Add(s, "Identifier", identifier)
+				}
+			}
+		}
+
+		config.Debugf("About to delete deployment: %v", format.CftToYaml(template))
+
+		results, err := ccdeploy.DeployTemplate(template)
+		if err != nil {
+			panic(err)
+		}
+
+		spinner.StopTimer()
+
+		for _, resource := range results.Resources {
+			fmt.Printf("Removed %v\n", resource)
+		}
+		fmt.Printf("Deployment %v successfully removed\n", name)
+
+		spinner.Push("Deleting state file")
+		err = s3.DeleteObject(bucketName, key)
+		if err != nil {
+			//lint:ignore ST1005 NA
+			panic(fmt.Errorf("Unable to delete state file %v/%v: %v", bucketName, key, err))
+		}
+		spinner.Pop()
+	},
+}
+
+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")
+}
diff --git a/internal/cmd/rain/rain.go b/internal/cmd/rain/rain.go
index d7a68bbc..57c66a2a 100644
--- a/internal/cmd/rain/rain.go
+++ b/internal/cmd/rain/rain.go
@@ -11,6 +11,8 @@ 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"
 	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"
@@ -83,6 +85,8 @@ 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, false, logs.Cmd)
 	addCommand(stackGroup, true, false, ls.Cmd)
 	addCommand(stackGroup, true, false, rm.Cmd)
diff --git a/internal/console/spinner/spinner.go b/internal/console/spinner/spinner.go
index 1c15c655..ad0406e0 100644
--- a/internal/console/spinner/spinner.go
+++ b/internal/console/spinner/spinner.go
@@ -97,6 +97,9 @@ func StopTimer() {
 	hasTimer = false
 
 	Pop()
+
+	// TODO: Return the elapsed time:
+	// time.Since(startTime).Truncate(time.Second),
 }
 
 // Pop removes the move recent status and stops the spinner if there are no more messages
diff --git a/internal/node/node.go b/internal/node/node.go
index 14d4893f..48166cee 100644
--- a/internal/node/node.go
+++ b/internal/node/node.go
@@ -3,6 +3,7 @@ package node
 import (
 	"encoding/json"
 	"fmt"
+	"strconv"
 
 	"github.com/aws-cloudformation/rain/internal/config"
 	"gopkg.in/yaml.v3"
@@ -113,6 +114,49 @@ func GetParent(node *yaml.Node, rootNode *yaml.Node, priorNode *yaml.Node) NodeP
 	return NodePair{Key: before, Value: found}
 }
 
+type SNode struct {
+	Kind    string
+	Value   string
+	Content []*SNode `json:",omitempty"`
+}
+
+func makeSNode(node *yaml.Node) *SNode {
+	var k string
+	switch node.Kind {
+	case yaml.DocumentNode:
+		k = "Document"
+	case yaml.SequenceNode:
+		k = "Sequence"
+	case yaml.MappingNode:
+		k = "Mapping"
+	case yaml.ScalarNode:
+		k = "Scalar"
+	case yaml.AliasNode:
+		k = "Alias"
+	default:
+		k = "?"
+	}
+
+	content := make([]*SNode, 0)
+	if node.Content != nil {
+		for _, child := range node.Content {
+			content = append(content, makeSNode(child))
+		}
+	}
+
+	s := SNode{k, node.Value, content}
+	return &s
+}
+
+// Convert a node to a shortened JSON for easier debugging
+func ToSJson(node *yaml.Node) string {
+	j, err := json.MarshalIndent(makeSNode(node), "", "  ")
+	if err != nil {
+		return fmt.Sprintf("Failed to marshal node to short json: %v:", err)
+	}
+	return string(j)
+}
+
 // Convert a node to JSON
 func ToJson(node *yaml.Node) string {
 	j, err := json.MarshalIndent(node, "", "  ")
@@ -167,3 +211,41 @@ func SetMapValue(parent *yaml.Node, name string, val *yaml.Node) {
 		parent.Content = append(parent.Content, val)
 	}
 }
+
+// Add adds a new scalar property to the map
+func Add(m *yaml.Node, name string, val string) {
+	m.Content = append(m.Content,
+		&yaml.Node{Kind: yaml.ScalarNode, Value: name})
+	m.Content = append(m.Content,
+		&yaml.Node{Kind: yaml.ScalarNode, Value: val})
+}
+
+// AddMap adds a new map to the parent node
+func AddMap(parent *yaml.Node, name string) *yaml.Node {
+	parent.Content = append(parent.Content,
+		&yaml.Node{Kind: yaml.ScalarNode, Value: name})
+	m := &yaml.Node{Kind: yaml.MappingNode, Content: make([]*yaml.Node, 0)}
+	parent.Content = append(parent.Content, m)
+	return m
+}
+
+// YamlVal converts node.Value to a string, int, or bool based on the Tag
+func YamlVal(n *yaml.Node) (any, error) {
+	var v any
+	var err error
+	switch n.Tag {
+	case "!!bool":
+		v, err = strconv.ParseBool(n.Value)
+		if err != nil {
+			return "", err
+		}
+	case "!!int":
+		v, err = strconv.ParseInt(n.Value, 10, 32)
+		if err != nil {
+			return "", err
+		}
+	default:
+		v = string(n.Value)
+	}
+	return v, nil
+}


Test patch:
diff --git a/internal/aws/ccapi/ccapi_test.go b/internal/aws/ccapi/ccapi_test.go
new file mode 100644
index 00000000..c9e607eb
--- /dev/null
+++ b/internal/aws/ccapi/ccapi_test.go
@@ -0,0 +1,75 @@
+package ccapi
+
+import (
+	"testing"
+
+	"github.com/aws-cloudformation/rain/cft/parse"
+	"github.com/aws-cloudformation/rain/internal/s11n"
+)
+
+func TestPatch(t *testing.T) {
+
+	// Create a PatchDocument based on the resource in this template
+	s := `
+Resources:
+  MyResource:
+    Type: X::Y::Z
+    Properties:
+      A: 5
+      B:
+        C: true
+        D: Hello
+        E:
+          F: false
+`
+	template, err := parse.String(s)
+	if err != nil {
+		t.Fatal(err)
+	}
+	// Dive down to the yaml resource properties
+	rootMap := template.Node.Content[0]
+	_, resources := s11n.GetMapValue(rootMap, "Resources")
+	_, myResource := s11n.GetMapValue(resources, "MyResource")
+	_, props := s11n.GetMapValue(myResource, "Properties")
+
+	// Create the patch string
+	patchDocument, err := CreatePatch(props, "{}")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	expected := `[
+    {"op":"add","path":"/A","value":5},
+    {"op":"add","path":"/B","value":{"C":true,"D":"Hello","E":{"F":false}}}
+]`
+
+	// Make sure they match
+	if expected != patchDocument {
+		t.Fatalf("Got:\n%v\nexpected:\n%v", patchDocument, expected)
+	}
+
+	patchDocument, err = CreatePatch(props, `
+{
+	"A": 5,
+	"B": {
+		"C": false,
+		"D": "World"
+	}
+}
+`)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	expected = `[
+    {"op":"replace","path":"/B/C","value":true},
+    {"op":"replace","path":"/B/D","value":"Hello"},
+    {"op":"add","path":"/B/E","value":{"F":false}}
+]`
+
+	// Make sure they match
+	if expected != patchDocument {
+		t.Fatalf("Got:\n%v\nexpected:\n%v", patchDocument, expected)
+	}
+
+}
diff --git a/internal/cmd/ccdeploy/ccdeploy_test.go b/internal/cmd/ccdeploy/ccdeploy_test.go
new file mode 100644
index 00000000..aab8f02c
--- /dev/null
+++ b/internal/cmd/ccdeploy/ccdeploy_test.go
@@ -0,0 +1,90 @@
+package ccdeploy
+
+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
+
+	g := graph.Empty()
+
+	a := graph.Node{Name: "a", Type: "Resources"}
+	b := graph.Node{Name: "b", Type: "Resources"}
+
+	g.Link(a, b)
+
+	ar := NewResource(a.Name, "AWS::S3::Bucket", Waiting, nil)
+	br := NewResource(b.Name, "AWS::S3::Bucket", Waiting, nil)
+
+	if ready(ar, &g) {
+		t.Errorf("ar should not be ready")
+	}
+
+	if !ready(br, &g) {
+		t.Errorf("br should be ready")
+	}
+
+	c := graph.Node{Name: "c", Type: "Resources"}
+	ar.State = Waiting
+	br.State = Deployed
+	cr := NewResource(c.Name, "AWS::S3::Bucket", Waiting, nil)
+	g.Link(b, c)
+
+	if !ready(cr, &g) {
+		t.Errorf("cr should be ready")
+	}
+
+	if ready(ar, &g) {
+		t.Errorf("ar should not be ready after adding c")
+	}
+
+}
+
+func TestVerifyDeletes(t *testing.T) {
+
+	g := graph.Empty()
+
+	a := graph.Node{Name: "A", Type: "Resources"}
+	b := graph.Node{Name: "B", Type: "Resources"}
+
+	g.Link(a, b) // A depends on B
+
+	resourceMap := make(map[string]*Resource)
+	ar := NewResource(a.Name, "AWS::S3::Bucket", Waiting, nil)
+	ar.Action = diff.Update
+	resourceMap[a.Name] = ar
+	br := NewResource(b.Name, "AWS::S3::Bucket", Waiting, nil)
+	br.Action = diff.Delete
+	resourceMap[b.Name] = br
+
+	err := verifyDeletes([]*Resource{br}, &g, resourceMap)
+	if err == nil {
+		t.Fatalf("Should have failed: A depends on B, which is being deleted")
+	}
+
+	br.Action = diff.Update
+	c := graph.Node{Name: "C", Type: "Resources"}
+	g.Link(b, c) // B depends on C
+	cr := NewResource(c.Name, "AWS::S3::Bucket", Waiting, nil)
+	cr.Action = diff.Delete
+	resourceMap[c.Name] = cr
+
+	err = verifyDeletes([]*Resource{cr}, &g, resourceMap)
+	if err == nil {
+		t.Fatalf("Should have failed: A depends on B->C, which is being deleted")
+	}
+
+	ar.Action = diff.Delete
+	br.Action = diff.Delete
+	err = verifyDeletes([]*Resource{ar, br, cr}, &g, resourceMap)
+	if err != nil {
+		t.Fatalf("Should not have failed: %v", err)
+	}
+
+}
diff --git a/internal/cmd/ccdeploy/update_test.go b/internal/cmd/ccdeploy/update_test.go
new file mode 100644
index 00000000..9455581f
--- /dev/null
+++ b/internal/cmd/ccdeploy/update_test.go
@@ -0,0 +1,38 @@
+package ccdeploy
+
+import (
+	"testing"
+
+	"github.com/aws-cloudformation/rain/cft/format"
+	"github.com/aws-cloudformation/rain/cft/parse"
+	"github.com/aws-cloudformation/rain/internal/config"
+)
+
+func TestUpdate(t *testing.T) {
+
+	// config.Debug = true
+
+	left, err := parse.File("../../../test/templates/ccdeploy1-state.yaml")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	right, err := parse.File("../../../test/templates/ccdeploy2.yaml")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	changes, err := update(left, right)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	output := format.String(changes, format.Options{
+		JSON:     false,
+		Unsorted: false,
+	})
+	config.Debugf("changes: %v", output)
+
+	// TODO - Confirm that the change template resources have the correct State:Action
+
+}
diff --git a/internal/cmd/rain/rain_test.go b/internal/cmd/rain/rain_test.go
index 9434edad..8f72cd7d 100644
--- a/internal/cmd/rain/rain_test.go
+++ b/internal/cmd/rain/rain_test.go
@@ -20,6 +20,8 @@ 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!)
 	//   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/test/templates/ccdeploy.yaml b/test/templates/ccdeploy.yaml
new file mode 100644
index 00000000..1fae0029
--- /dev/null
+++ b/test/templates/ccdeploy.yaml
@@ -0,0 +1,24 @@
+Resources:
+  A:
+    Type: AWS::SQS::Queue
+    Metadata:
+      Comment: A1
+    Properties:
+      DelaySeconds: 1
+      MessageRetentionPeriod: 200000
+      QueueName: ccdeploy-a
+    
+  B:
+    Type: AWS::SQS::Queue
+    DependsOn: A
+    Metadata: 
+      Comment: B1
+    Properties:
+      QueueName: ccdeploy-b
+    
+      
+  C:
+    Type: AWS::SQS::Queue
+    Properties:
+      QueueName: ccdeploy-c
+
diff --git a/test/templates/ccdeploy1-state.yaml b/test/templates/ccdeploy1-state.yaml
new file mode 100644
index 00000000..db3d3fdd
--- /dev/null
+++ b/test/templates/ccdeploy1-state.yaml
@@ -0,0 +1,64 @@
+Resources:
+  A:
+    Type: AWS::SQS::Queue
+    Metadata:
+      Comment: A1
+    Properties:
+      DelaySeconds: 1
+      QueueName: ccdeploy-a
+
+  B:
+    Type: AWS::SQS::Queue
+    DependsOn: A
+    Metadata:
+      Comment: B1
+    Properties:
+      QueueName: ccdeploy-b
+
+  C:
+    Type: AWS::SQS::Queue
+    Properties:
+      QueueName: ccdeploy-c
+
+State:
+  LastWriteTime: "2023-10-24T16:08:38-07:00"
+
+  ResourceModels:
+    A:
+      Identifier: https://sqs.us-east-1.amazonaws.com/755952356119/ccdeploy-a
+      Model:
+        Arn: arn:aws:sqs:us-east-1:755952356119:ccdeploy-a
+        DelaySeconds: 1
+        MaximumMessageSize: 262144
+        MessageRetentionPeriod: 345600
+        QueueName: ccdeploy-a
+        QueueUrl: https://sqs.us-east-1.amazonaws.com/755952356119/ccdeploy-a
+        ReceiveMessageWaitTimeSeconds: 0
+        SqsManagedSseEnabled: true
+        VisibilityTimeout: 30
+    C:
+      Identifier: https://sqs.us-east-1.amazonaws.com/755952356119/ccdeploy-c
+      Model:
+        Arn: arn:aws:sqs:us-east-1:755952356119:ccdeploy-c
+        DelaySeconds: 0
+        MaximumMessageSize: 262144
+        MessageRetentionPeriod: 345600
+        QueueName: ccdeploy-c
+        QueueUrl: https://sqs.us-east-1.amazonaws.com/755952356119/ccdeploy-c
+        ReceiveMessageWaitTimeSeconds: 0
+        SqsManagedSseEnabled: true
+        VisibilityTimeout: 30
+    B:
+      Identifier: https://sqs.us-east-1.amazonaws.com/755952356119/ccdeploy-b
+      Model:
+        Arn: arn:aws:sqs:us-east-1:755952356119:ccdeploy-b
+        DelaySeconds: 0
+        MaximumMessageSize: 262144
+        MessageRetentionPeriod: 345600
+        QueueName: ccdeploy-b
+        QueueUrl: https://sqs.us-east-1.amazonaws.com/755952356119/ccdeploy-b
+        ReceiveMessageWaitTimeSeconds: 0
+        SqsManagedSseEnabled: true
+        VisibilityTimeout: 30
+
+  Lock: ffd56502-ad23-4a3f-a4ca-6fc2da169ffd
diff --git a/test/templates/ccdeploy2.yaml b/test/templates/ccdeploy2.yaml
new file mode 100644
index 00000000..34ba8e2f
--- /dev/null
+++ b/test/templates/ccdeploy2.yaml
@@ -0,0 +1,25 @@
+Resources:
+  A:
+    Type: AWS::SQS::Queue
+    Metadata:
+      Comment: A1
+    Properties:
+      DelaySeconds: 2
+      QueueName: ccdeploy-a
+    
+  B:
+    Type: AWS::SQS::Queue
+    DependsOn: A
+    Metadata: 
+      Comment: B1
+    Properties:
+      QueueName: ccdeploy-b
+
+  D:
+    Type: AWS::SQS::Queue
+    Properties:
+      QueueName: ccdeploy-d
+    
+
+      
+