Repository: klothoplatform/klotho
Pull request #797 (2023-11-30 19:30:40)

Create an overall engine test with a few seed inputs
Also fixes a few errors found while running:
- Operational action now considers edge uniqueness before reusing resources
- Op Eval keys should sort properly
- Fix path selection debug graphs

```
go test -v -timeout 1m -run '^TestEngine$' ./pkg/engine2
=== RUN   TestEngine
=== RUN   TestEngine/2_routes.input.yaml
=== PAUSE TestEngine/2_routes.input.yaml
=== RUN   TestEngine/extend_graph.input.yaml
=== PAUSE TestEngine/extend_graph.input.yaml
=== RUN   TestEngine/single_lambda.input.yaml
=== PAUSE TestEngine/single_lambda.input.yaml
=== CONT  TestEngine/2_routes.input.yaml
=== CONT  TestEngine/single_lambda.input.yaml
=== CONT  TestEngine/extend_graph.input.yaml
--- PASS: TestEngine (0.00s)
    --- PASS: TestEngine/single_lambda.input.yaml (0.17s)
    --- PASS: TestEngine/extend_graph.input.yaml (0.35s)
    --- PASS: TestEngine/2_routes.input.yaml (1.38s)
PASS
ok      github.com/klothoplatform/klotho/pkg/engine2    1.389s
```



Patch:
diff --git a/.prettierignore b/.prettierignore
index 0484a9d6..a7e86649 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,2 +1,3 @@
 gen/
 compiled*/
+pkg/engine2/testdata/
diff --git a/Makefile b/Makefile
index 63f091bd..0d28a9a6 100644
--- a/Makefile
+++ b/Makefile
@@ -21,3 +21,12 @@ iac: $(GO_FILES) $(IAC_TEMPLATES)
 .PHONY: clean_debug
 clean_debug:
 	find . \( -name '*.gv' -or -name '*.gv.svg' \) -delete
+
+
+.PHONY: engine_test
+engine_test:
+	go test -v -timeout 1m -run '^TestEngine$$' ./pkg/engine2
+
+.PHONY: regen_tests
+regen_tests:
+	find pkg/engine2/testdata -type f -name '*.input.yaml' -exec ./create_test.sh {} \;
diff --git a/pkg/engine2/.gitignore b/pkg/engine2/.gitignore
new file mode 100644
index 00000000..eaa0c55f
--- /dev/null
+++ b/pkg/engine2/.gitignore
@@ -0,0 +1,1 @@
+/test_debug/
diff --git a/pkg/engine2/operational_eval/debug.go b/pkg/engine2/operational_eval/debug.go
index 39afe220..6f6367d7 100644
--- a/pkg/engine2/operational_eval/debug.go
+++ b/pkg/engine2/operational_eval/debug.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"io"
 	"os"
+	"path/filepath"
 	"sync"
 
 	"github.com/dominikbraun/graph"
@@ -38,6 +39,9 @@ func PrintGraph(g Graph) {
 }
 
 func (eval *Evaluator) writeGraph(prefix string) {
+	if debugDir := os.Getenv("KLOTHO_DEBUG_DIR"); debugDir != "" {
+		prefix = filepath.Join(debugDir, prefix)
+	}
 	var wg sync.WaitGroup
 	wg.Add(2)
 	go func() {
diff --git a/pkg/engine2/operational_eval/dot.go b/pkg/engine2/operational_eval/dot.go
index 59c37040..3896409d 100644
--- a/pkg/engine2/operational_eval/dot.go
+++ b/pkg/engine2/operational_eval/dot.go
@@ -6,9 +6,7 @@ import (
 	"io"
 	"strings"
 
-	construct "github.com/klothoplatform/klotho/pkg/construct2"
 	"github.com/klothoplatform/klotho/pkg/dot"
-	knowledgebase "github.com/klothoplatform/klotho/pkg/knowledge_base2"
 )
 
 const (
@@ -21,14 +19,21 @@ const (
 func keyAttributes(eval *Evaluator, key Key) map[string]string {
 	attribs := make(map[string]string)
 	var style []string
-	if !key.Ref.Resource.IsZero() {
+	switch key.keyType() {
+	case keyTypeProperty:
 		attribs["label"] = fmt.Sprintf(`%s\n#%s`, key.Ref.Resource, key.Ref.Property)
 		attribs["shape"] = "box"
-	} else if key.GraphState != "" {
+
+	case keyTypeEdge:
+		attribs["label"] = fmt.Sprintf(`%s\n→ %s`, key.Edge.Source, key.Edge.Target)
+		attribs["shape"] = "parallelogram"
+
+	case keyTypeGraphState:
 		attribs["label"] = string(key.GraphState)
 		attribs["shape"] = "box"
 		style = append(style, "dashed")
-	} else if key.PathSatisfication != (knowledgebase.EdgePathSatisfaction{}) {
+
+	case keyTypePathExpand:
 		attribs["label"] = fmt.Sprintf(`%s\n→ %s`, key.Edge.Source, key.Edge.Target)
 		var extra []string
 		if key.PathSatisfication.Classification != "" {
@@ -46,13 +51,7 @@ func keyAttributes(eval *Evaluator, key Key) map[string]string {
 
 		attribs["shape"] = "parallelogram"
 		style = append(style, "dashed")
-	} else if key.Edge != (construct.SimpleEdge{}) {
-		attribs["label"] = fmt.Sprintf(`%s\n→ %s`, key.Edge.Source, key.Edge.Target)
-		attribs["shape"] = "parallelogram"
-	} else if key.Internal != "" {
-		attribs["label"] = fmt.Sprintf(`|%s|`, key.Internal)
-		attribs["shape"] = "polygon"
-	} else {
+	default:
 		attribs["label"] = fmt.Sprintf(`%s\n(UNKOWN)`, key)
 		attribs["color"] = "#fc8803"
 	}
diff --git a/pkg/engine2/operational_eval/evaluator.go b/pkg/engine2/operational_eval/evaluator.go
index 594ce951..01502e3a 100644
--- a/pkg/engine2/operational_eval/evaluator.go
+++ b/pkg/engine2/operational_eval/evaluator.go
@@ -34,7 +34,6 @@ type (
 		Edge              construct.SimpleEdge
 		GraphState        graphStateRepr
 		PathSatisfication knowledgebase.EdgePathSatisfaction
-		Internal          string
 	}
 
 	Vertex interface {
@@ -55,6 +54,9 @@ type (
 		// edges is map[source]targets
 		edges map[Key]set.Set[Key]
 	}
+
+	// keyType makes it easy to switch on, and by being an int makes sorting of keys easier
+	keyType int
 )
 
 const (
@@ -71,6 +73,13 @@ const (
 	NotReadyMax
 )
 
+const (
+	keyTypeProperty keyType = iota
+	keyTypeEdge
+	keyTypeGraphState
+	keyTypePathExpand
+)
+
 func NewEvaluator(ctx solution_context.SolutionContext) *Evaluator {
 	return &Evaluator{
 		Solution:    ctx,
@@ -80,14 +89,36 @@ func NewEvaluator(ctx solution_context.SolutionContext) *Evaluator {
 	}
 }
 
-func (key Key) String() string {
+func (key Key) keyType() keyType {
 	if !key.Ref.Resource.IsZero() {
-		return key.Ref.String()
+		return keyTypeProperty
 	}
 	if key.GraphState != "" {
-		return string(key.GraphState)
+		return keyTypeGraphState
 	}
 	if key.PathSatisfication != (knowledgebase.EdgePathSatisfaction{}) {
+		return keyTypePathExpand
+	}
+	// make sure edge is last because PathExpand also has an edge
+	if key.Edge != (construct.SimpleEdge{}) {
+		return keyTypeEdge
+	}
+	return -1
+}
+
+func (key Key) String() string {
+	kt := key.keyType()
+	switch kt {
+	case keyTypeProperty:
+		return key.Ref.String()
+
+	case keyTypeEdge:
+		return key.Edge.String()
+
+	case keyTypeGraphState:
+		return string(key.GraphState)
+
+	case keyTypePathExpand:
 		args := []string{
 			key.Edge.String(),
 		}
@@ -102,52 +133,34 @@ func (key Key) String() string {
 		}
 		return fmt.Sprintf("Expand(%s)", strings.Join(args, ", "))
 	}
-	if key.Edge != (construct.SimpleEdge{}) {
-		return key.Edge.String()
-	}
-	if key.Internal != "" {
-		return fmt.Sprintf("|%s|", key.Internal)
-	}
 	return "<empty>"
 }
 
 func (key Key) Less(other Key) bool {
-	if !key.Ref.Resource.IsZero() {
-		if other.Ref.Resource.IsZero() {
-			return true
-		}
+	myKT := key.keyType()
+	otherKT := other.keyType()
+	if myKT != otherKT {
+		return myKT < otherKT
+	}
+	switch myKT {
+	case keyTypeProperty:
 		if key.Ref.Resource != other.Ref.Resource {
 			return construct.ResourceIdLess(key.Ref.Resource, other.Ref.Resource)
 		}
 		return key.Ref.Property < other.Ref.Property
-	}
-	if key.GraphState != "" {
-		if other.GraphState == "" {
-			return true
-		}
+
+	case keyTypeEdge:
+		return key.Edge.Less(other.Edge)
+
+	case keyTypeGraphState:
 		return key.GraphState < other.GraphState
-	}
-	// if key.PathSatisfication.valid {
-	// 	if !other.PathSatisfication.valid {
-	// 		return true
-	// 	}
-	// 	if key.PathSatisfication.Classification != other.PathSatisfication.Classification {
-	// 		return key.PathSatisfication.Classification < other.PathSatisfication.Classification
-	// 	}
-	// 	return key.Edge.Less(other.Edge)
-	// }
-	if key.Edge != (construct.SimpleEdge{}) {
-		if other.Edge == (construct.SimpleEdge{}) {
-			return true
+
+	case keyTypePathExpand:
+		if key.PathSatisfication.Classification != other.PathSatisfication.Classification {
+			return key.PathSatisfication.Classification < other.PathSatisfication.Classification
 		}
 		return key.Edge.Less(other.Edge)
 	}
-	if key.Internal != "" {
-		if other.Internal == "" {
-			return true
-		}
-		return key.Internal < other.Internal
-	}
 	// Empty key, put that last, though it should never happen
 	return false
 }
diff --git a/pkg/engine2/operational_rule/operational_action.go b/pkg/engine2/operational_rule/operational_action.go
index 05eb1cb2..6bc7bf6c 100644
--- a/pkg/engine2/operational_rule/operational_action.go
+++ b/pkg/engine2/operational_rule/operational_action.go
@@ -120,6 +120,24 @@ func (action *operationalResourceAction) createUniqueResources(resource *constru
 func (action *operationalResourceAction) useAvailableResources(resource *construct.Resource) error {
 	configCtx := solution_context.DynamicCtx(action.ruleCtx.Solution)
 	availableResources := make(set.Set[*construct.Resource])
+
+	var err error
+	var directDeps, oppositeDeps map[construct.ResourceId]map[construct.ResourceId]construct.Edge
+	if action.Step.Direction == knowledgebase.DirectionDownstream {
+		var perr error
+		directDeps, err = action.ruleCtx.Solution.DataflowGraph().AdjacencyMap()
+		oppositeDeps, perr = action.ruleCtx.Solution.DataflowGraph().PredecessorMap()
+		err = errors.Join(err, perr)
+	} else {
+		var perr error
+		directDeps, err = action.ruleCtx.Solution.DataflowGraph().PredecessorMap()
+		oppositeDeps, perr = action.ruleCtx.Solution.DataflowGraph().AdjacencyMap()
+		err = errors.Join(err, perr)
+	}
+	if err != nil {
+		return err
+	}
+
 	// Next we will loop through and try to use available resources if the unique flag is not set
 	for _, resourceSelector := range action.Step.Resources {
 		ids, err := resourceSelector.ExtractResourceIds(configCtx, action.ruleCtx.Data)
@@ -193,11 +211,70 @@ func (action *operationalResourceAction) useAvailableResources(resource *constru
 				} else if err != nil {
 					return fmt.Errorf("error checking %s satisfies namespace: %w", resId, err)
 				}
-				availableResources.Add(res)
+
+				var edge construct.SimpleEdge
+				if action.Step.Direction == knowledgebase.DirectionDownstream {
+					edge = construct.SimpleEdge{Source: resource.ID, Target: res.ID}
+				} else {
+					edge = construct.SimpleEdge{Source: res.ID, Target: resource.ID}
+				}
+				// Check to see if the edge already exists, if it does, then we should be able to reuse the resource
+				_, err = action.ruleCtx.Solution.RawView().Edge(edge.Source, edge.Target)
+				if err == nil {
+					availableResources.Add(res)
+					continue
+				} else if !errors.Is(err, graph.ErrEdgeNotFound) {
+					return err
+				}
+				edgeTmpl := action.ruleCtx.Solution.KnowledgeBase().GetEdgeTemplate(edge.Source, edge.Target)
+				if edgeTmpl == nil {
+					continue
+				}
+				if edgeTmpl.Unique == (knowledgebase.Unique{}) {
+					// many-to-many is okay
+					availableResources.Add(res)
+					continue
+				}
+				switch action.Step.Direction {
+				case knowledgebase.DirectionDownstream:
+					if !edgeTmpl.Unique.Target {
+						// one-to-many is okay
+						availableResources.Add(res)
+						continue
+					}
+
+				case knowledgebase.DirectionUpstream:
+					if !edgeTmpl.Unique.Source {
+						// many-to-one are okay
+						availableResources.Add(res)
+						continue
+					}
+				}
+
+				// is unique, check to see if we already have one
+				// this works for both up/downstream because `directDeps` is the correct direction
+				hasType := false
+				for d := range directDeps[resource.ID] {
+					if d.QualifiedTypeName() == res.ID.QualifiedTypeName() {
+						hasType = true
+						break
+					}
+				}
+				if !hasType {
+					for d := range oppositeDeps[res.ID] {
+						if d.QualifiedTypeName() == resource.ID.QualifiedTypeName() {
+							hasType = true
+							break
+						}
+					}
+				}
+				if !hasType {
+					availableResources.Add(res)
+				}
 			}
 		}
 	}
-	err := action.placeResources(resource, availableResources)
+	err = action.placeResources(resource, availableResources)
 	if err != nil {
 		return fmt.Errorf("error during operational resource action while placing resources: %w", err)
 	}
diff --git a/pkg/engine2/path_selection/debug.go b/pkg/engine2/path_selection/debug.go
index c9115883..c2d27bc7 100644
--- a/pkg/engine2/path_selection/debug.go
+++ b/pkg/engine2/path_selection/debug.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"io"
 	"os"
+	"path/filepath"
 	"strings"
 
 	construct "github.com/klothoplatform/klotho/pkg/construct2"
@@ -20,14 +21,19 @@ import (
 var seenFiles = make(set.Set[string])
 
 func writeGraph(input ExpansionInput, working, result construct.Graph) {
-	err := os.Mkdir("selection", 0755)
+	dir := "selection"
+	if debugDir := os.Getenv("KLOTHO_DEBUG_DIR"); debugDir != "" {
+		dir = filepath.Join(debugDir, "selection")
+	}
+	err := os.MkdirAll(dir, 0755)
 	if err != nil && !errors.Is(err, os.ErrExist) {
 		zap.S().Warnf("Could not create folder for selection diagram: %v", err)
 		return
 	}
 
-	fprefix := fmt.Sprintf("selection/%s-%s", input.Dep.Source.ID, input.Dep.Target.ID)
+	fprefix := fmt.Sprintf("%s-%s", input.Dep.Source.ID, input.Dep.Target.ID)
 	fprefix = strings.ReplaceAll(fprefix, ":", "_") // some filesystems (NTFS) don't like colons in filenames
+	fprefix = filepath.Join(dir, fprefix)
 
 	f, err := os.OpenFile(fprefix+".gv", os.O_RDWR|os.O_CREATE, 0755)
 	if err != nil {
diff --git a/pkg/engine2/path_selection/dot.go b/pkg/engine2/path_selection/dot.go
index 3ad05c71..89d35162 100644
--- a/pkg/engine2/path_selection/dot.go
+++ b/pkg/engine2/path_selection/dot.go
@@ -28,9 +28,15 @@ func attributes(result construct.Graph, id construct.ResourceId, props graph.Ver
 		a["label"] = id.String()
 		a["shape"] = "box"
 	}
-	if name := props.Attributes["new_name"]; name != "" {
-		// vertex is a renamed phantom
+	if newidstr := props.Attributes["new_id"]; newidstr != "" {
+		// vertex is a renamed node
 		a["style"] = "dashed"
+		newid := id
+		_ = newid.UnmarshalText([]byte(newidstr))
+		name := newid.Name
+		if newid.Namespace != "" {
+			name = fmt.Sprintf("%s:%s", newid.Namespace, name)
+		}
 		a["label"] = fmt.Sprintf(`%s\n:%s`, id.QualifiedTypeName(), name)
 	}
 	if _, err := result.Vertex(id); err == nil {
@@ -64,8 +70,8 @@ func graphToDOTCluster(class string, working, result construct.Graph, out io.Wri
 	fixId := func(id construct.ResourceId) construct.ResourceId {
 		_, tProps, _ := working.VertexWithProperties(id)
 		if tProps.Attributes != nil {
-			if name := tProps.Attributes["new_name"]; name != "" {
-				id.Name = name
+			if newid := tProps.Attributes["new_id"]; newid != "" {
+				errs = errors.Join(errs, id.UnmarshalText([]byte(newid)))
 			}
 		}
 		return id
diff --git a/pkg/engine2/path_selection/path_expansion.go b/pkg/engine2/path_selection/path_expansion.go
index 527bf86d..2a7a3540 100644
--- a/pkg/engine2/path_selection/path_expansion.go
+++ b/pkg/engine2/path_selection/path_expansion.go
@@ -9,6 +9,7 @@ import (
 	construct "github.com/klothoplatform/klotho/pkg/construct2"
 	"github.com/klothoplatform/klotho/pkg/engine2/operational_rule"
 	"github.com/klothoplatform/klotho/pkg/engine2/solution_context"
+	"github.com/klothoplatform/klotho/pkg/graph_addons"
 	knowledgebase "github.com/klothoplatform/klotho/pkg/knowledge_base2"
 	"github.com/klothoplatform/klotho/pkg/set"
 	"go.uber.org/zap"
@@ -76,28 +77,34 @@ func expandEdge(
 		return nil, errors.Join(errs, fmt.Errorf("could not find shortest path between %s and %s: %w", input.Dep.Source.ID, input.Dep.Target.ID, err))
 	}
 
-	name := fmt.Sprintf("%s_%s", input.Dep.Source.ID.Name, input.Dep.Target.ID.Name)
+	name := fmt.Sprintf("%s-%s", input.Dep.Source.ID.Name, input.Dep.Target.ID.Name)
 	// rename phantom nodes
-	result := make([]construct.ResourceId, len(path))
+	result := make(construct.Path, len(path))
 	for i, id := range path {
 		if strings.HasPrefix(id.Name, PHANTOM_PREFIX) {
+			oldId := id
 			id.Name = name
-			if node, err := ctx.RawView().Vertex(id); err == nil && node != nil {
-				name = fmt.Sprintf("%s_%s-2", name, id.Name)
-				id.Name = name
+			for suffix := 0; suffix < 1000; suffix++ {
+				_, err := ctx.RawView().Vertex(id)
+				if err != nil && errors.Is(err, graph.ErrVertexNotFound) {
+					break
+				} else if err != nil {
+					return nil, err
+				}
+				id.Name = fmt.Sprintf("%s-%d", name, suffix)
 			}
-			_, props, err := input.TempGraph.VertexWithProperties(id)
+			_, props, err := input.TempGraph.VertexWithProperties(oldId)
 			if err == nil && props.Attributes != nil {
-				props.Attributes["new_name"] = name
+				props.Attributes["new_id"] = id.String()
 			}
 		}
 		result[i] = id
 	}
-	resultResources, errs := addResourceListToGraph(ctx, g, result)
+	resultResources, errs := addPathToGraph(ctx, g, result)
 	if errs != nil {
 		return nil, errors.Join(errs, err)
 	}
-	errs = errors.Join(errs, handleProperties(ctx, resultResources, g))
+	errs = errors.Join(errs, handleProperties(ctx, resultResources, input.TempGraph, g))
 	edges, err := findSubExpansionsToRun(resultResources, ctx)
 	return edges, errors.Join(errs, err)
 }
@@ -173,6 +180,7 @@ func findSubExpansionsToRun(
 func handleProperties(
 	ctx solution_context.SolutionContext,
 	resultResources []*construct.Resource,
+	tempGraph construct.Graph,
 	g construct.Graph,
 ) error {
 	var errs error
@@ -201,40 +209,46 @@ func handleProperties(
 				for _, selector := range step.Resources {
 					if step.Direction == knowledgebase.DirectionDownstream && i < len(resultResources)-1 {
 						downstreamRes := resultResources[i+1]
-						if canUse, err := selector.CanUse(solution_context.DynamicCtx(ctx),
-							knowledgebase.DynamicValueData{Resource: res.ID}, downstreamRes); canUse && err == nil {
+						canUse, err := selector.CanUse(
+							solution_context.DynamicCtx(ctx),
+							knowledgebase.DynamicValueData{Resource: res.ID},
+							downstreamRes,
+						)
+						if canUse && err == nil {
 							err = opRuleCtx.SetField(res, downstreamRes, step)
 							if err != nil {
 								errs = errors.Join(errs, err)
 							}
 							if prop.Namespace && exists != nil {
-								err = construct.PropagateUpdatedId(ctx.OperationalView(), id)
-								if err != nil {
-									errs = errors.Join(errs, err)
-								}
+								errs = errors.Join(errs, construct.PropagateUpdatedId(ctx.OperationalView(), oldId))
 							}
 						}
 					} else if i > 0 {
 						upstreamRes := resultResources[i-1]
-						if canUse, err := selector.CanUse(solution_context.DynamicCtx(ctx),
-							knowledgebase.DynamicValueData{Resource: res.ID}, upstreamRes); canUse && err == nil {
+						canUse, err := selector.CanUse(
+							solution_context.DynamicCtx(ctx),
+							knowledgebase.DynamicValueData{Resource: res.ID},
+							upstreamRes,
+						)
+						if canUse && err == nil {
 							err = opRuleCtx.SetField(res, upstreamRes, step)
 							if err != nil {
 								errs = errors.Join(errs, err)
 							}
 							if prop.Namespace && exists != nil {
-								err = construct.PropagateUpdatedId(ctx.OperationalView(), id)
-								if err != nil {
-									errs = errors.Join(errs, err)
-								}
+								errs = errors.Join(errs, construct.PropagateUpdatedId(ctx.OperationalView(), id))
 							}
 						}
 
 					}
 				}
 			}
-			if prop.Namespace && oldId.Namespace != res.ID.Namespace {
-				errs = errors.Join(errs, construct.ReplaceResource(g, oldId, res))
+			if prop.Namespace && oldId != res.ID {
+				errs = errors.Join(errs, graph_addons.ReplaceVertex(g, oldId, res, construct.ResourceHasher))
+				_, props, err := tempGraph.VertexWithProperties(oldId)
+				if err == nil && props.Attributes != nil {
+					props.Attributes["new_id"] = res.ID.String()
+				}
 			}
 			return nil
 		}
@@ -488,14 +502,14 @@ func connectThroughNamespace(src, target *construct.Resource, ctx solution_conte
 	return
 }
 
-func addResourceListToGraph(ctx solution_context.SolutionContext, g construct.Graph, resources []construct.ResourceId) (
+func addPathToGraph(ctx solution_context.SolutionContext, g construct.Graph, path construct.Path) (
 	[]*construct.Resource,
 	error,
 ) {
 	var result []*construct.Resource
 	var errs error
 	var prevRes construct.ResourceId
-	for i, resource := range resources {
+	for i, resource := range path {
 		r, err := ctx.RawView().Vertex(resource)
 		if err != nil && !errors.Is(err, graph.ErrVertexNotFound) {
 			errs = errors.Join(errs, err)
diff --git a/pkg/infra/iac3/templates/aws/efs_file_system/factory.ts b/pkg/infra/iac3/templates/aws/efs_file_system/factory.ts
index 6e71585f..8232a1c0 100644
--- a/pkg/infra/iac3/templates/aws/efs_file_system/factory.ts
+++ b/pkg/infra/iac3/templates/aws/efs_file_system/factory.ts
@@ -54,5 +54,6 @@ function create(args: Args): aws.efs.FileSystem {
 function properties(object: aws.efs.FileSystem, args: Args) {
     return {
         Id: object.id,
+        Arn: object.arn,
     }
 }
diff --git a/pkg/templates/aws/edges/api_method-api_integration.yaml b/pkg/templates/aws/edges/api_method-api_integration.yaml
index 3584afc4..5fc0043b 100644
--- a/pkg/templates/aws/edges/api_method-api_integration.yaml
+++ b/pkg/templates/aws/edges/api_method-api_integration.yaml
@@ -14,3 +14,4 @@ operational_rules:
                 keysToMapWithDefault true |
                 toJson
             }}
+unique: one-to-one
diff --git a/pkg/templates/aws/edges/api_resource-api_method.yaml b/pkg/templates/aws/edges/api_resource-api_method.yaml
index c723d4c2..1a1ac1b3 100644
--- a/pkg/templates/aws/edges/api_resource-api_method.yaml
+++ b/pkg/templates/aws/edges/api_resource-api_method.yaml
@@ -1,3 +1,4 @@
 source: aws:api_resource
 target: aws:api_method
 deployment_order_reversed: true
+unique: one-to-one
diff --git a/pkg/templates/aws/edges/iam_role-efs_file_system.yaml b/pkg/templates/aws/edges/iam_role-efs_file_system.yaml
new file mode 100644
index 00000000..a869d5b1
--- /dev/null
+++ b/pkg/templates/aws/edges/iam_role-efs_file_system.yaml
@@ -0,0 +1,17 @@
+source: aws:iam_role
+target: aws:efs_file_system
+operational_rules:
+  - configuration_rules:
+      - resource: '{{ .Source }}'
+        configuration:
+          field: InlinePolicies
+          value:
+            - Name: '{{ .Target.Name }}-policy'
+              Policy:
+                Version: '2012-10-17'
+                Statement:
+                  - Action:
+                      - efs:Client*
+                    Effect: Allow
+                    Resource:
+                      - '{{ .Target  }}#Arn'
diff --git a/pkg/templates/aws/resources/api_integration.yaml b/pkg/templates/aws/resources/api_integration.yaml
index 7cee2f82..11481188 100644
--- a/pkg/templates/aws/resources/api_integration.yaml
+++ b/pkg/templates/aws/resources/api_integration.yaml
@@ -15,7 +15,7 @@ properties:
   Resource:
     type: resource(aws:api_resource)
     operational_rule:
-      if: | 
+      if: |
         {{ ne (fieldValue "Route" .Self) "/" }}
       steps:
         - direction: upstream
@@ -31,6 +31,9 @@ properties:
         - direction: upstream
           resources:
             - selector: aws:api_method
+              properties:
+                RestApi: '{{ fieldValue "RestApi" .Self }}'
+                Resource: '{{ fieldValue "Resource" .Self }}'
   RequestParameters:
     type: map(string,string)
     operational_rule:
diff --git a/pkg/templates/aws/resources/ecs_task_definition.yaml b/pkg/templates/aws/resources/ecs_task_definition.yaml
index df324f7c..d1959f26 100644
--- a/pkg/templates/aws/resources/ecs_task_definition.yaml
+++ b/pkg/templates/aws/resources/ecs_task_definition.yaml
@@ -11,7 +11,7 @@ properties:
             - aws:ecr_image:{{ .Self.Name }}-image
           unique: true
   MountPoints:
-    type: list
+    type: set
     properties:
       ContainerPath:
         type: string
@@ -57,7 +57,7 @@ properties:
     type: string
     default_value: awsvpc
   PortMappings:
-    type: list
+    type: set
     default_value:
       - ContainerPort: 80
         HostPort: 80
@@ -74,7 +74,7 @@ properties:
     default_value:
       - FARGATE
   EfsVolumes:
-    type: list
+    type: set
     properties:
       Name:
         type: string
diff --git a/pkg/templates/aws/resources/efs_file_system.yaml b/pkg/templates/aws/resources/efs_file_system.yaml
index 0f8adf95..393bf4b3 100644
--- a/pkg/templates/aws/resources/efs_file_system.yaml
+++ b/pkg/templates/aws/resources/efs_file_system.yaml
@@ -35,11 +35,16 @@ properties:
     type: string
     configuration_disabled: true
     deploy_time: true
+  Arn:
+    type: string
+    configuration_disabled: true
+    deploy_time: true
 
 path_satisfaction:
   as_target:
     - network
     - efs_access
+    - permissions
 
 classification:
   is:
diff --git a/pkg/templates/aws/resources/subnet.yaml b/pkg/templates/aws/resources/subnet.yaml
index 7ba710b8..65f8e053 100644
--- a/pkg/templates/aws/resources/subnet.yaml
+++ b/pkg/templates/aws/resources/subnet.yaml
@@ -55,6 +55,10 @@ properties:
     type: bool
     default_value: false
 
+path_satisfaction:
+  as_source:
+    - network
+
 classification:
   is:
     - network


Test patch:
diff --git a/create_test.sh b/create_test.sh
new file mode 100755
index 00000000..61414aac
--- /dev/null
+++ b/create_test.sh
@@ -0,0 +1,33 @@
+#!/bin/sh
+
+set -e
+
+out_dir=$(mktemp -d)
+export KLOTHO_DEBUG_DIR=$out_dir/debug
+mkdir -p $KLOTHO_DEBUG_DIR
+
+name=$(basename $1)
+name=${name%.*}
+name=${name%.input}
+
+echo "Running $name"
+
+# Run the engine
+go run ./cmd/engine Run \
+  -i "$1" \
+  -c "$1" \
+  -o "$out_dir" > $out_dir/out.log 2> $out_dir/err.log
+
+echo "Successfully ran $name, copying results to testdata"
+
+test_dir="pkg/engine2/testdata"
+
+if [ ! "$test_dir/$name.input.yaml" -ef "$1" ]; then
+  cp $1 "$test_dir/$name.input.yaml"
+fi
+
+cp "$out_dir/resources.yaml" "$test_dir/$name.expect.yaml"
+cp "$out_dir/dataflow-topology.yaml" "$test_dir/$name.dataflow-viz.yaml"
+cp "$out_dir/iac-topology.yaml" "$test_dir/$name.iac-viz.yaml"
+
+rm -rf $out_dir
diff --git a/pkg/engine2/engine_test.go b/pkg/engine2/engine_test.go
new file mode 100644
index 00000000..aeafb8d2
--- /dev/null
+++ b/pkg/engine2/engine_test.go
@@ -0,0 +1,137 @@
+package engine2
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	construct "github.com/klothoplatform/klotho/pkg/construct2"
+	"github.com/stretchr/testify/assert"
+	"gopkg.in/yaml.v3"
+)
+
+func TestEngine(t *testing.T) {
+	os.Setenv("KLOTHO_DEBUG_DIR", "test_debug")
+	if err := os.MkdirAll("test_debug", 0755); err != nil {
+		t.Fatal(err)
+	}
+
+	tests, err := filepath.Glob(filepath.Join("testdata", "*.input.yaml"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, p := range tests {
+		tc := engineTestCase{inputPath: p}
+		t.Run(filepath.Base(p), tc.Test)
+	}
+}
+
+type engineTestCase struct {
+	inputPath string
+}
+
+func (tc engineTestCase) readGraph(t *testing.T, f io.Reader) FileFormat {
+	t.Helper()
+	var ff FileFormat
+	err := yaml.NewDecoder(f).Decode(&ff)
+	if err != nil {
+		t.Fatal(fmt.Errorf("failed to read graph: %w", err))
+	}
+	return ff
+}
+
+func (tc engineTestCase) Test(t *testing.T) {
+	t.Parallel()
+	inputYaml, err := os.Open(tc.inputPath)
+	if err != nil {
+		t.Fatal(fmt.Errorf("failed to open input file: %w", err))
+	}
+	defer inputYaml.Close()
+	expectYaml, err := os.Open(strings.Replace(tc.inputPath, ".input.yaml", ".expect.yaml", 1))
+	if err != nil {
+		t.Fatal(fmt.Errorf("failed to open expected output file: %w", err))
+	}
+	defer expectYaml.Close()
+
+	inputFile := tc.readGraph(t, inputYaml)
+	expectContent, err := io.ReadAll(expectYaml)
+	if err != nil {
+		t.Fatal(fmt.Errorf("failed to read expected output file: %w", err))
+	}
+
+	main := EngineMain{}
+	err = main.AddEngine()
+	if err != nil {
+		t.Fatal(fmt.Errorf("failed to add engine: %w", err))
+	}
+	context := &EngineContext{
+		Constraints:  inputFile.Constraints,
+		InitialState: inputFile.Graph,
+	}
+	err = main.Engine.Run(context)
+	if err != nil {
+		t.Fatal(fmt.Errorf("failed to run engine: %w", err))
+	}
+
+	sol := context.Solutions[0]
+	actualContent, err := yaml.Marshal(construct.YamlGraph{Graph: sol.DataflowGraph()})
+	if err != nil {
+		t.Fatal(fmt.Errorf("failed to marshal actual output: %w", err))
+	}
+
+	assert.Equal(t, string(expectContent), string(actualContent))
+
+	// Always visualize views even if we're not testing them to make sure that it at least succeeds
+	vizFiles, err := main.Engine.VisualizeViews(sol)
+	if err != nil {
+		t.Fatal(fmt.Errorf("failed to generate views: %w", err))
+	}
+
+	iacVizFile, err := os.Open(strings.Replace(tc.inputPath, ".input.yaml", ".iac-viz.yaml", 1))
+	if err != nil && !os.IsNotExist(err) {
+		t.Fatal(fmt.Errorf("failed to open iac viz file: %w", err))
+	}
+	if iacVizFile != nil {
+		defer iacVizFile.Close()
+	}
+
+	dataflowVizFile, err := os.Open(strings.Replace(tc.inputPath, ".input.yaml", ".dataflow-viz.yaml", 1))
+	if err != nil && !os.IsNotExist(err) {
+		t.Fatal(fmt.Errorf("failed to open dataflow viz file: %w", err))
+	}
+	if dataflowVizFile != nil {
+		defer dataflowVizFile.Close()
+	}
+	if iacVizFile == nil && dataflowVizFile == nil {
+		return
+	}
+
+	buf := new(bytes.Buffer)
+
+	for _, f := range vizFiles {
+		buf.Reset()
+		_, err := f.WriteTo(buf)
+		if err != nil {
+			t.Fatal(fmt.Errorf("failed to write viz file %s: %w", f.Path(), err))
+		}
+		if strings.HasPrefix(f.Path(), "iac-") && iacVizFile != nil {
+			iacExpect, err := io.ReadAll(iacVizFile)
+			if err != nil {
+				t.Error(fmt.Errorf("failed to read iac viz file: %w", err))
+			} else {
+				assert.Equal(t, string(iacExpect), buf.String(), "IaC topology")
+			}
+		} else if strings.HasPrefix(f.Path(), "dataflow-") && dataflowVizFile != nil {
+			dataflowExpect, err := io.ReadAll(dataflowVizFile)
+			if err != nil {
+				t.Error(fmt.Errorf("failed to read dataflow viz file: %w", err))
+			} else {
+				assert.Equal(t, string(dataflowExpect), buf.String(), "dataflow topology")
+			}
+		}
+	}
+}
diff --git a/pkg/engine2/testdata/2_routes.dataflow-viz.yaml b/pkg/engine2/testdata/2_routes.dataflow-viz.yaml
new file mode 100755
index 00000000..9c3d954e
--- /dev/null
+++ b/pkg/engine2/testdata/2_routes.dataflow-viz.yaml
@@ -0,0 +1,26 @@
+provider: aws
+resources:
+  lambda_function/lambda_function_1:
+    children: aws:ecr_image:lambda_function_1-image,aws:iam_role:lambda_function_1-ExecutionRole,aws:log_group:lambda_function_1-log-group
+
+
+  lambda_function/lambda_function_0:
+    children: aws:ecr_image:lambda_function_0-image,aws:iam_role:lambda_function_0-ExecutionRole,aws:log_group:lambda_function_0-log-group
+
+
+  rest_api/rest_api_1:
+
+  aws:api_integration:rest_api_1/integ1:
+    parent: rest_api/rest_api_1
+
+  aws:api_integration:rest_api_1/integ1 -> lambda_function/lambda_function_1:
+    path: aws:lambda_permission:integ1-lambda_function_1
+
+
+  aws:api_integration:rest_api_1/integ0:
+    parent: rest_api/rest_api_1
+
+  aws:api_integration:rest_api_1/integ0 -> lambda_function/lambda_function_0:
+    path: aws:lambda_permission:integ0-lambda_function_0
+
+
diff --git a/pkg/engine2/testdata/2_routes.expect.yaml b/pkg/engine2/testdata/2_routes.expect.yaml
new file mode 100755
index 00000000..89a2ef1c
--- /dev/null
+++ b/pkg/engine2/testdata/2_routes.expect.yaml
@@ -0,0 +1,171 @@
+resources:
+    aws:api_stage:rest_api_1:api_stage-0:
+        Deployment: aws:api_deployment:rest_api_1:api_deployment-0
+        RestApi: aws:rest_api:rest_api_1
+        StageName: stage
+    aws:api_deployment:rest_api_1:api_deployment-0:
+        RestApi: aws:rest_api:rest_api_1
+        Triggers:
+            api_method-0: api_method-0
+            api_method-1: api_method-1
+            integ0: integ0
+            integ1: integ1
+    aws:rest_api:rest_api_1:
+        BinaryMediaTypes:
+            - application/octet-stream
+            - image/*
+        Stages:
+            - aws:api_stage:rest_api_1:api_stage-0
+    aws:api_resource:rest_api_1:lambda0:
+        FullPath: /lambda0
+        PathPart: lambda0
+        RestApi: aws:rest_api:rest_api_1
+    aws:api_resource:rest_api_1:lambda1:
+        FullPath: /lambda1
+        PathPart: lambda1
+        RestApi: aws:rest_api:rest_api_1
+    aws:api_resource:rest_api_1:api_resource-0:
+        FullPath: /lambda0/api
+        ParentResource: aws:api_resource:rest_api_1:lambda0
+        PathPart: api
+        RestApi: aws:rest_api:rest_api_1
+    aws:api_resource:rest_api_1:api_resource-1:
+        FullPath: /lambda1/api
+        ParentResource: aws:api_resource:rest_api_1:lambda1
+        PathPart: api
+        RestApi: aws:rest_api:rest_api_1
+    aws:api_method:rest_api_1:api_method-0:
+        Authorization: NONE
+        HttpMethod: ANY
+        RequestParameters: {}
+        Resource: aws:api_resource:rest_api_1:api_resource-0
+        RestApi: aws:rest_api:rest_api_1
+    aws:api_method:rest_api_1:api_method-1:
+        Authorization: NONE
+        HttpMethod: ANY
+        RequestParameters: {}
+        Resource: aws:api_resource:rest_api_1:api_resource-1
+        RestApi: aws:rest_api:rest_api_1
+    aws:api_integration:rest_api_1:integ0:
+        IntegrationHttpMethod: POST
+        Method: aws:api_method:rest_api_1:api_method-0
+        RequestParameters: {}
+        Resource: aws:api_resource:rest_api_1:api_resource-0
+        RestApi: aws:rest_api:rest_api_1
+        Route: /lambda0/api
+        Target: aws:lambda_function:lambda_function_0
+        Type: AWS_PROXY
+        Uri: aws:lambda_function:lambda_function_0#LambdaIntegrationUri
+    aws:api_integration:rest_api_1:integ1:
+        IntegrationHttpMethod: POST
+        Method: aws:api_method:rest_api_1:api_method-1
+        RequestParameters: {}
+        Resource: aws:api_resource:rest_api_1:api_resource-1
+        RestApi: aws:rest_api:rest_api_1
+        Route: /lambda1/api
+        Target: aws:lambda_function:lambda_function_1
+        Type: AWS_PROXY
+        Uri: aws:lambda_function:lambda_function_1#LambdaIntegrationUri
+    aws:lambda_permission:integ0-lambda_function_0:
+        Action: lambda:InvokeFunction
+        Function: aws:lambda_function:lambda_function_0
+        Principal: apigateway.amazonaws.com
+        Source: aws:rest_api:rest_api_1#ChildResources
+    aws:lambda_permission:integ1-lambda_function_1:
+        Action: lambda:InvokeFunction
+        Function: aws:lambda_function:lambda_function_1
+        Principal: apigateway.amazonaws.com
+        Source: aws:rest_api:rest_api_1#ChildResources
+    aws:lambda_function:lambda_function_0:
+        ExecutionRole: aws:iam_role:lambda_function_0-ExecutionRole
+        Image: aws:ecr_image:lambda_function_0-image
+        LogGroup: aws:log_group:lambda_function_0-log-group
+        MemorySize: 512
+        Timeout: 180
+    aws:lambda_function:lambda_function_1:
+        ExecutionRole: aws:iam_role:lambda_function_1-ExecutionRole
+        Image: aws:ecr_image:lambda_function_1-image
+        LogGroup: aws:log_group:lambda_function_1-log-group
+        MemorySize: 512
+        Timeout: 180
+    aws:ecr_image:lambda_function_0-image:
+        Context: .
+        Dockerfile: lambda_function_0-image.Dockerfile
+        Repo: aws:ecr_repo:ecr_repo-0
+    aws:iam_role:lambda_function_0-ExecutionRole:
+        AssumeRolePolicyDoc:
+            Statement:
+                - Action:
+                    - sts:AssumeRole
+                  Effect: Allow
+                  Principal:
+                    Service:
+                        - lambda.amazonaws.com
+            Version: "2012-10-17"
+        ManagedPolicies:
+            - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
+    aws:SERVICE_API:lambda_function_0-lambda_function_0-log-group:
+    aws:ecr_image:lambda_function_1-image:
+        Context: .
+        Dockerfile: lambda_function_1-image.Dockerfile
+        Repo: aws:ecr_repo:ecr_repo-0
+    aws:iam_role:lambda_function_1-ExecutionRole:
+        AssumeRolePolicyDoc:
+            Statement:
+                - Action:
+                    - sts:AssumeRole
+                  Effect: Allow
+                  Principal:
+                    Service:
+                        - lambda.amazonaws.com
+            Version: "2012-10-17"
+        ManagedPolicies:
+            - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
+    aws:log_group:lambda_function_0-log-group:
+        LogGroupName: /aws/lambda/lambda_function_0
+        RetentionInDays: 5
+    aws:ecr_repo:ecr_repo-0:
+        ForceDelete: true
+    aws:log_group:lambda_function_1-log-group:
+        LogGroupName: /aws/lambda/lambda_function_1
+        RetentionInDays: 5
+edges:
+    aws:api_stage:rest_api_1:api_stage-0 -> aws:api_deployment:rest_api_1:api_deployment-0:
+    aws:api_stage:rest_api_1:api_stage-0 -> aws:rest_api:rest_api_1:
+    aws:api_deployment:rest_api_1:api_deployment-0 -> aws:api_integration:rest_api_1:integ0:
+    aws:api_deployment:rest_api_1:api_deployment-0 -> aws:api_integration:rest_api_1:integ1:
+    aws:api_deployment:rest_api_1:api_deployment-0 -> aws:api_method:rest_api_1:api_method-0:
+    aws:api_deployment:rest_api_1:api_deployment-0 -> aws:api_method:rest_api_1:api_method-1:
+    aws:api_deployment:rest_api_1:api_deployment-0 -> aws:rest_api:rest_api_1:
+    aws:rest_api:rest_api_1 -> aws:api_integration:rest_api_1:integ0:
+    aws:rest_api:rest_api_1 -> aws:api_integration:rest_api_1:integ1:
+    aws:rest_api:rest_api_1 -> aws:api_method:rest_api_1:api_method-0:
+    aws:rest_api:rest_api_1 -> aws:api_method:rest_api_1:api_method-1:
+    aws:rest_api:rest_api_1 -> aws:api_resource:rest_api_1:api_resource-0:
+    aws:rest_api:rest_api_1 -> aws:api_resource:rest_api_1:api_resource-1:
+    aws:rest_api:rest_api_1 -> aws:api_resource:rest_api_1:lambda0:
+    aws:rest_api:rest_api_1 -> aws:api_resource:rest_api_1:lambda1:
+    aws:api_resource:rest_api_1:lambda0 -> aws:api_resource:rest_api_1:api_resource-0:
+    aws:api_resource:rest_api_1:lambda1 -> aws:api_resource:rest_api_1:api_resource-1:
+    aws:api_resource:rest_api_1:api_resource-0 -> aws:api_integration:rest_api_1:integ0:
+    aws:api_resource:rest_api_1:api_resource-0 -> aws:api_method:rest_api_1:api_method-0:
+    aws:api_resource:rest_api_1:api_resource-1 -> aws:api_integration:rest_api_1:integ1:
+    aws:api_resource:rest_api_1:api_resource-1 -> aws:api_method:rest_api_1:api_method-1:
+    aws:api_method:rest_api_1:api_method-0 -> aws:api_integration:rest_api_1:integ0:
+    aws:api_method:rest_api_1:api_method-1 -> aws:api_integration:rest_api_1:integ1:
+    aws:api_integration:rest_api_1:integ0 -> aws:lambda_permission:integ0-lambda_function_0:
+    aws:api_integration:rest_api_1:integ1 -> aws:lambda_permission:integ1-lambda_function_1:
+    aws:lambda_permission:integ0-lambda_function_0 -> aws:lambda_function:lambda_function_0:
+    aws:lambda_permission:integ1-lambda_function_1 -> aws:lambda_function:lambda_function_1:
+    aws:lambda_function:lambda_function_0 -> aws:SERVICE_API:lambda_function_0-lambda_function_0-log-group:
+    aws:lambda_function:lambda_function_0 -> aws:ecr_image:lambda_function_0-image:
+    aws:lambda_function:lambda_function_0 -> aws:iam_role:lambda_function_0-ExecutionRole:
+    aws:lambda_function:lambda_function_1 -> aws:SERVICE_API:lambda_function_0-lambda_function_0-log-group:
+    aws:lambda_function:lambda_function_1 -> aws:ecr_image:lambda_function_1-image:
+    aws:lambda_function:lambda_function_1 -> aws:iam_role:lambda_function_1-ExecutionRole:
+    aws:ecr_image:lambda_function_0-image -> aws:ecr_repo:ecr_repo-0:
+    aws:iam_role:lambda_function_0-ExecutionRole -> aws:log_group:lambda_function_0-log-group:
+    aws:SERVICE_API:lambda_function_0-lambda_function_0-log-group -> aws:log_group:lambda_function_0-log-group:
+    aws:SERVICE_API:lambda_function_0-lambda_function_0-log-group -> aws:log_group:lambda_function_1-log-group:
+    aws:ecr_image:lambda_function_1-image -> aws:ecr_repo:ecr_repo-0:
+    aws:iam_role:lambda_function_1-ExecutionRole -> aws:log_group:lambda_function_1-log-group:
diff --git a/pkg/engine2/testdata/2_routes.iac-viz.yaml b/pkg/engine2/testdata/2_routes.iac-viz.yaml
new file mode 100755
index 00000000..a134775b
--- /dev/null
+++ b/pkg/engine2/testdata/2_routes.iac-viz.yaml
@@ -0,0 +1,81 @@
+provider: aws
+resources:
+  rest_api/rest_api_1:
+
+  ecr_repo/ecr_repo-0:
+
+  log_group/lambda_function_0-log-group:
+
+  log_group/lambda_function_1-log-group:
+
+  aws:api_resource:rest_api_1/lambda0:
+  aws:api_resource:rest_api_1/lambda0 -> rest_api/rest_api_1:
+
+  ecr_image/lambda_function_0-image:
+  ecr_image/lambda_function_0-image -> ecr_repo/ecr_repo-0:
+
+  iam_role/lambda_function_0-executionrole:
+  iam_role/lambda_function_0-executionrole -> log_group/lambda_function_0-log-group:
+
+  aws:api_resource:rest_api_1/lambda1:
+  aws:api_resource:rest_api_1/lambda1 -> rest_api/rest_api_1:
+
+  ecr_image/lambda_function_1-image:
+  ecr_image/lambda_function_1-image -> ecr_repo/ecr_repo-0:
+
+  iam_role/lambda_function_1-executionrole:
+  iam_role/lambda_function_1-executionrole -> log_group/lambda_function_1-log-group:
+
+  aws:api_resource:rest_api_1/api_resource-0:
+  aws:api_resource:rest_api_1/api_resource-0 -> aws:api_resource:rest_api_1/lambda0:
+  aws:api_resource:rest_api_1/api_resource-0 -> rest_api/rest_api_1:
+
+  lambda_function/lambda_function_0:
+  lambda_function/lambda_function_0 -> ecr_image/lambda_function_0-image:
+  lambda_function/lambda_function_0 -> iam_role/lambda_function_0-executionrole:
+
+  aws:api_resource:rest_api_1/api_resource-1:
+  aws:api_resource:rest_api_1/api_resource-1 -> aws:api_resource:rest_api_1/lambda1:
+  aws:api_resource:rest_api_1/api_resource-1 -> rest_api/rest_api_1:
+
+  lambda_function/lambda_function_1:
+  lambda_function/lambda_function_1 -> ecr_image/lambda_function_1-image:
+  lambda_function/lambda_function_1 -> iam_role/lambda_function_1-executionrole:
+
+  aws:api_method:rest_api_1/api_method-0:
+  aws:api_method:rest_api_1/api_method-0 -> aws:api_resource:rest_api_1/api_resource-0:
+  aws:api_method:rest_api_1/api_method-0 -> rest_api/rest_api_1:
+
+  lambda_permission/integ0-lambda_function_0:
+  lambda_permission/integ0-lambda_function_0 -> lambda_function/lambda_function_0:
+
+  aws:api_method:rest_api_1/api_method-1:
+  aws:api_method:rest_api_1/api_method-1 -> aws:api_resource:rest_api_1/api_resource-1:
+  aws:api_method:rest_api_1/api_method-1 -> rest_api/rest_api_1:
+
+  lambda_permission/integ1-lambda_function_1:
+  lambda_permission/integ1-lambda_function_1 -> lambda_function/lambda_function_1:
+
+  aws:api_integration:rest_api_1/integ0:
+  aws:api_integration:rest_api_1/integ0 -> aws:api_method:rest_api_1/api_method-0:
+  aws:api_integration:rest_api_1/integ0 -> aws:api_resource:rest_api_1/api_resource-0:
+  aws:api_integration:rest_api_1/integ0 -> lambda_permission/integ0-lambda_function_0:
+  aws:api_integration:rest_api_1/integ0 -> rest_api/rest_api_1:
+
+  aws:api_integration:rest_api_1/integ1:
+  aws:api_integration:rest_api_1/integ1 -> aws:api_method:rest_api_1/api_method-1:
+  aws:api_integration:rest_api_1/integ1 -> aws:api_resource:rest_api_1/api_resource-1:
+  aws:api_integration:rest_api_1/integ1 -> lambda_permission/integ1-lambda_function_1:
+  aws:api_integration:rest_api_1/integ1 -> rest_api/rest_api_1:
+
+  aws:api_deployment:rest_api_1/api_deployment-0:
+  aws:api_deployment:rest_api_1/api_deployment-0 -> aws:api_integration:rest_api_1/integ0:
+  aws:api_deployment:rest_api_1/api_deployment-0 -> aws:api_integration:rest_api_1/integ1:
+  aws:api_deployment:rest_api_1/api_deployment-0 -> aws:api_method:rest_api_1/api_method-0:
+  aws:api_deployment:rest_api_1/api_deployment-0 -> aws:api_method:rest_api_1/api_method-1:
+  aws:api_deployment:rest_api_1/api_deployment-0 -> rest_api/rest_api_1:
+
+  aws:api_stage:rest_api_1/api_stage-0:
+  aws:api_stage:rest_api_1/api_stage-0 -> aws:api_deployment:rest_api_1/api_deployment-0:
+  aws:api_stage:rest_api_1/api_stage-0 -> rest_api/rest_api_1:
+
diff --git a/pkg/engine2/testdata/2_routes.input.yaml b/pkg/engine2/testdata/2_routes.input.yaml
new file mode 100644
index 00000000..86f20910
--- /dev/null
+++ b/pkg/engine2/testdata/2_routes.input.yaml
@@ -0,0 +1,48 @@
+constraints:
+  - node: aws:rest_api:rest_api_1
+    operator: add
+    scope: application
+  # lambda 0
+  - node: aws:lambda_function:lambda_function_0
+    operator: add
+    scope: application
+  - node: aws:api_integration:rest_api_1:integ0
+    operator: add
+    scope: application
+  - operator: must_exist
+    scope: edge
+    target:
+      source: aws:rest_api:rest_api_1
+      target: aws:api_integration:rest_api_1:integ0
+  - operator: equals
+    property: Route
+    scope: resource
+    target: aws:api_integration:rest_api_1:integ0
+    value: /lambda0/api
+  - operator: must_exist
+    scope: edge
+    target:
+      source: aws:api_integration:rest_api_1:integ0
+      target: aws:lambda_function:lambda_function_0
+  # lambda 1
+  - node: aws:lambda_function:lambda_function_1
+    operator: add
+    scope: application
+  - node: aws:api_integration:rest_api_1:integ1
+    operator: add
+    scope: application
+  - operator: must_exist
+    scope: edge
+    target:
+      source: aws:rest_api:rest_api_1
+      target: aws:api_integration:rest_api_1:integ1
+  - operator: equals
+    property: Route
+    scope: resource
+    target: aws:api_integration:rest_api_1:integ1
+    value: /lambda1/api
+  - operator: must_exist
+    scope: edge
+    target:
+      source: aws:api_integration:rest_api_1:integ1
+      target: aws:lambda_function:lambda_function_1
diff --git a/pkg/engine2/testdata/extend_graph.dataflow-viz.yaml b/pkg/engine2/testdata/extend_graph.dataflow-viz.yaml
new file mode 100755
index 00000000..3fe44517
--- /dev/null
+++ b/pkg/engine2/testdata/extend_graph.dataflow-viz.yaml
@@ -0,0 +1,17 @@
+provider: aws
+resources:
+  lambda_function/lambda_function_0:
+    children: aws:ecr_image:lambda_function_0-image,aws:iam_role:lambda_function_0-ExecutionRole,aws:log_group:lambda_function_0-log-group
+
+
+  rest_api/rest_api_1:
+
+  dynamodb_table/dynamodb_table_3:
+
+  aws:api_integration:rest_api_1/rest_api_1_integration_0:
+    parent: rest_api/rest_api_1
+
+  aws:api_integration:rest_api_1/rest_api_1_integration_0 -> lambda_function/lambda_function_0:
+    path: aws:lambda_permission:rest_api_1_integration_0_lambda_function_0
+
+
diff --git a/pkg/engine2/testdata/extend_graph.expect.yaml b/pkg/engine2/testdata/extend_graph.expect.yaml
new file mode 100755
index 00000000..cef3c791
--- /dev/null
+++ b/pkg/engine2/testdata/extend_graph.expect.yaml
@@ -0,0 +1,97 @@
+resources:
+    aws:api_stage:rest_api_1:api_stage-0:
+        Deployment: aws:api_deployment:rest_api_1:api_deployment-0
+        RestApi: aws:rest_api:rest_api_1
+        StageName: stage
+    aws:dynamodb_table:dynamodb_table_3:
+        Attributes:
+            - Name: id
+              Type: S
+        BillingMode: PAY_PER_REQUEST
+        HashKey: id
+    aws:api_deployment:rest_api_1:api_deployment-0:
+        RestApi: aws:rest_api:rest_api_1
+        Triggers:
+            rest_api_1_integration_0: rest_api_1_integration_0
+            rest_api_1_integration_0_method: rest_api_1_integration_0_method
+    aws:rest_api:rest_api_1:
+        BinaryMediaTypes:
+            - application/octet-stream
+            - image/*
+        Stages:
+            - aws:api_stage:rest_api_1:api_stage-0
+    aws:api_resource:rest_api_1:api_resource-0:
+        FullPath: /{proxy+}
+        PathPart: '{proxy+}'
+        RestApi: aws:rest_api:rest_api_1
+    aws:api_method:rest_api_1:rest_api_1_integration_0_method:
+        Authorization: NONE
+        HttpMethod: ANY
+        RequestParameters:
+            method.request.path.proxy: true
+        Resource: aws:api_resource:rest_api_1:api_resource-0
+        RestApi: aws:rest_api:rest_api_1
+    aws:api_integration:rest_api_1:rest_api_1_integration_0:
+        IntegrationHttpMethod: POST
+        Method: aws:api_method:rest_api_1:rest_api_1_integration_0_method
+        RequestParameters:
+            integration.request.path.proxy: method.request.path.proxy
+        Resource: aws:api_resource:rest_api_1:api_resource-0
+        RestApi: aws:rest_api:rest_api_1
+        Route: /{proxy+}
+        Target: aws:lambda_function:lambda_function_0
+        Type: AWS_PROXY
+        Uri: aws:lambda_function:lambda_function_0#LambdaIntegrationUri
+    aws:lambda_permission:rest_api_1_integration_0_lambda_function_0:
+        Action: lambda:InvokeFunction
+        Function: aws:lambda_function:lambda_function_0
+        Principal: apigateway.amazonaws.com
+        Source: aws:rest_api:rest_api_1#ChildResources
+    aws:lambda_function:lambda_function_0:
+        ExecutionRole: aws:iam_role:lambda_function_0-ExecutionRole
+        Image: aws:ecr_image:lambda_function_0-image
+        LogGroup: aws:log_group:lambda_function_0-log-group
+        MemorySize: 512
+        Timeout: 180
+    aws:SERVICE_API:lambda_function_0_lambda_function_0-log-group:
+    aws:ecr_image:lambda_function_0-image:
+        Context: .
+        Dockerfile: lambda_function_0-image.Dockerfile
+        Repo: aws:ecr_repo:ecr_repo-0
+    aws:iam_role:lambda_function_0-ExecutionRole:
+        AssumeRolePolicyDoc:
+            Statement:
+                - Action:
+                    - sts:AssumeRole
+                  Effect: Allow
+                  Principal:
+                    Service:
+                        - lambda.amazonaws.com
+            Version: "2012-10-17"
+        ManagedPolicies:
+            - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
+    aws:ecr_repo:ecr_repo-0:
+        ForceDelete: true
+    aws:log_group:lambda_function_0-log-group:
+        LogGroupName: /aws/lambda/lambda_function_0
+        RetentionInDays: 5
+edges:
+    aws:api_stage:rest_api_1:api_stage-0 -> aws:api_deployment:rest_api_1:api_deployment-0:
+    aws:api_stage:rest_api_1:api_stage-0 -> aws:rest_api:rest_api_1:
+    aws:api_deployment:rest_api_1:api_deployment-0 -> aws:api_integration:rest_api_1:rest_api_1_integration_0:
+    aws:api_deployment:rest_api_1:api_deployment-0 -> aws:api_method:rest_api_1:rest_api_1_integration_0_method:
+    aws:api_deployment:rest_api_1:api_deployment-0 -> aws:rest_api:rest_api_1:
+    aws:rest_api:rest_api_1 -> aws:api_integration:rest_api_1:rest_api_1_integration_0:
+    aws:rest_api:rest_api_1 -> aws:api_method:rest_api_1:rest_api_1_integration_0_method:
+    aws:rest_api:rest_api_1 -> aws:api_resource:rest_api_1:api_resource-0:
+    aws:api_resource:rest_api_1:api_resource-0 -> aws:api_integration:rest_api_1:rest_api_1_integration_0:
+    aws:api_resource:rest_api_1:api_resource-0 -> aws:api_method:rest_api_1:rest_api_1_integration_0_method:
+    aws:api_method:rest_api_1:rest_api_1_integration_0_method -> aws:api_integration:rest_api_1:rest_api_1_integration_0:
+    aws:api_integration:rest_api_1:rest_api_1_integration_0 -> aws:lambda_permission:rest_api_1_integration_0_lambda_function_0:
+    aws:lambda_permission:rest_api_1_integration_0_lambda_function_0 -> aws:lambda_function:lambda_function_0:
+    aws:lambda_function:lambda_function_0 -> aws:SERVICE_API:lambda_function_0_lambda_function_0-log-group:
+    aws:lambda_function:lambda_function_0 -> aws:ecr_image:lambda_function_0-image:
+    aws:lambda_function:lambda_function_0 -> aws:iam_role:lambda_function_0-ExecutionRole:
+    aws:SERVICE_API:lambda_function_0_lambda_function_0-log-group -> aws:log_group:lambda_function_0-log-group:
+    aws:ecr_image:lambda_function_0-image -> aws:ecr_repo:ecr_repo-0:
+    aws:iam_role:lambda_function_0-ExecutionRole -> aws:log_group:lambda_function_0-log-group:
diff --git a/pkg/engine2/testdata/extend_graph.iac-viz.yaml b/pkg/engine2/testdata/extend_graph.iac-viz.yaml
new file mode 100755
index 00000000..60a5a748
--- /dev/null
+++ b/pkg/engine2/testdata/extend_graph.iac-viz.yaml
@@ -0,0 +1,45 @@
+provider: aws
+resources:
+  ecr_repo/ecr_repo-0:
+
+  log_group/lambda_function_0-log-group:
+
+  rest_api/rest_api_1:
+
+  ecr_image/lambda_function_0-image:
+  ecr_image/lambda_function_0-image -> ecr_repo/ecr_repo-0:
+
+  iam_role/lambda_function_0-executionrole:
+  iam_role/lambda_function_0-executionrole -> log_group/lambda_function_0-log-group:
+
+  aws:api_resource:rest_api_1/api_resource-0:
+  aws:api_resource:rest_api_1/api_resource-0 -> rest_api/rest_api_1:
+
+  lambda_function/lambda_function_0:
+  lambda_function/lambda_function_0 -> ecr_image/lambda_function_0-image:
+  lambda_function/lambda_function_0 -> iam_role/lambda_function_0-executionrole:
+
+  aws:api_method:rest_api_1/rest_api_1_integration_0_method:
+  aws:api_method:rest_api_1/rest_api_1_integration_0_method -> aws:api_resource:rest_api_1/api_resource-0:
+  aws:api_method:rest_api_1/rest_api_1_integration_0_method -> rest_api/rest_api_1:
+
+  lambda_permission/rest_api_1_integration_0_lambda_function_0:
+  lambda_permission/rest_api_1_integration_0_lambda_function_0 -> lambda_function/lambda_function_0:
+
+  aws:api_integration:rest_api_1/rest_api_1_integration_0:
+  aws:api_integration:rest_api_1/rest_api_1_integration_0 -> aws:api_method:rest_api_1/rest_api_1_integration_0_method:
+  aws:api_integration:rest_api_1/rest_api_1_integration_0 -> aws:api_resource:rest_api_1/api_resource-0:
+  aws:api_integration:rest_api_1/rest_api_1_integration_0 -> lambda_permission/rest_api_1_integration_0_lambda_function_0:
+  aws:api_integration:rest_api_1/rest_api_1_integration_0 -> rest_api/rest_api_1:
+
+  aws:api_deployment:rest_api_1/api_deployment-0:
+  aws:api_deployment:rest_api_1/api_deployment-0 -> aws:api_integration:rest_api_1/rest_api_1_integration_0:
+  aws:api_deployment:rest_api_1/api_deployment-0 -> aws:api_method:rest_api_1/rest_api_1_integration_0_method:
+  aws:api_deployment:rest_api_1/api_deployment-0 -> rest_api/rest_api_1:
+
+  dynamodb_table/dynamodb_table_3:
+
+  aws:api_stage:rest_api_1/api_stage-0:
+  aws:api_stage:rest_api_1/api_stage-0 -> aws:api_deployment:rest_api_1/api_deployment-0:
+  aws:api_stage:rest_api_1/api_stage-0 -> rest_api/rest_api_1:
+
diff --git a/pkg/engine2/testdata/extend_graph.input.yaml b/pkg/engine2/testdata/extend_graph.input.yaml
new file mode 100644
index 00000000..a8c5e3d4
--- /dev/null
+++ b/pkg/engine2/testdata/extend_graph.input.yaml
@@ -0,0 +1,95 @@
+constraints:
+  - node: aws:dynamodb_table:dynamodb_table_3
+    operator: add
+    scope: application
+resources:
+  aws:api_stage:rest_api_1:api_stage-0:
+    Deployment: aws:api_deployment:rest_api_1:api_deployment-0
+    RestApi: aws:rest_api:rest_api_1
+    StageName: stage
+  aws:api_deployment:rest_api_1:api_deployment-0:
+    RestApi: aws:rest_api:rest_api_1
+    Triggers:
+      rest_api_1_integration_0: rest_api_1_integration_0
+      rest_api_1_integration_0_method: rest_api_1_integration_0_method
+  aws:rest_api:rest_api_1:
+    BinaryMediaTypes:
+      - application/octet-stream
+      - image/*
+    Stages:
+      - aws:api_stage:rest_api_1:api_stage-0
+  aws:api_resource:rest_api_1:api_resource-0:
+    FullPath: /{proxy+}
+    PathPart: '{proxy+}'
+    RestApi: aws:rest_api:rest_api_1
+  aws:api_method:rest_api_1:rest_api_1_integration_0_method:
+    Authorization: NONE
+    HttpMethod: ANY
+    RequestParameters:
+      method.request.path.proxy: true
+    Resource: aws:api_resource:rest_api_1:api_resource-0
+    RestApi: aws:rest_api:rest_api_1
+  aws:api_integration:rest_api_1:rest_api_1_integration_0:
+    IntegrationHttpMethod: POST
+    Method: aws:api_method:rest_api_1:rest_api_1_integration_0_method
+    RequestParameters:
+      integration.request.path.proxy: method.request.path.proxy
+    Resource: aws:api_resource:rest_api_1:api_resource-0
+    RestApi: aws:rest_api:rest_api_1
+    Route: /{proxy+}
+    Target: aws:lambda_function:lambda_function_0
+    Type: AWS_PROXY
+    Uri: aws:lambda_function:lambda_function_0#LambdaIntegrationUri
+  aws:lambda_permission:rest_api_1_integration_0_lambda_function_0:
+    Action: lambda:InvokeFunction
+    Function: aws:lambda_function:lambda_function_0
+    Principal: apigateway.amazonaws.com
+    Source: aws:rest_api:rest_api_1#ChildResources
+  aws:lambda_function:lambda_function_0:
+    ExecutionRole: aws:iam_role:lambda_function_0-ExecutionRole
+    Image: aws:ecr_image:lambda_function_0-image
+    LogGroup: aws:log_group:lambda_function_0-log-group
+    MemorySize: 512
+    Timeout: 180
+  aws:SERVICE_API:lambda_function_0_lambda_function_0-log-group:
+  aws:ecr_image:lambda_function_0-image:
+    Context: .
+    Dockerfile: lambda_function_0-image.Dockerfile
+    Repo: aws:ecr_repo:ecr_repo-0
+  aws:iam_role:lambda_function_0-ExecutionRole:
+    AssumeRolePolicyDoc:
+      Statement:
+        - Action:
+            - sts:AssumeRole
+          Effect: Allow
+          Principal:
+            Service:
+              - lambda.amazonaws.com
+      Version: '2012-10-17'
+    ManagedPolicies:
+      - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
+  aws:ecr_repo:ecr_repo-0:
+    ForceDelete: true
+  aws:log_group:lambda_function_0-log-group:
+    LogGroupName: /aws/lambda/lambda_function_0
+    RetentionInDays: 5
+edges:
+  aws:api_stage:rest_api_1:api_stage-0 -> aws:api_deployment:rest_api_1:api_deployment-0:
+  aws:api_stage:rest_api_1:api_stage-0 -> aws:rest_api:rest_api_1:
+  aws:api_deployment:rest_api_1:api_deployment-0 -> aws:api_integration:rest_api_1:rest_api_1_integration_0:
+  aws:api_deployment:rest_api_1:api_deployment-0 -> aws:api_method:rest_api_1:rest_api_1_integration_0_method:
+  aws:api_deployment:rest_api_1:api_deployment-0 -> aws:rest_api:rest_api_1:
+  aws:rest_api:rest_api_1 -> aws:api_integration:rest_api_1:rest_api_1_integration_0:
+  aws:rest_api:rest_api_1 -> aws:api_method:rest_api_1:rest_api_1_integration_0_method:
+  aws:rest_api:rest_api_1 -> aws:api_resource:rest_api_1:api_resource-0:
+  aws:api_resource:rest_api_1:api_resource-0 -> aws:api_integration:rest_api_1:rest_api_1_integration_0:
+  aws:api_resource:rest_api_1:api_resource-0 -> aws:api_method:rest_api_1:rest_api_1_integration_0_method:
+  aws:api_method:rest_api_1:rest_api_1_integration_0_method -> aws:api_integration:rest_api_1:rest_api_1_integration_0:
+  aws:api_integration:rest_api_1:rest_api_1_integration_0 -> aws:lambda_permission:rest_api_1_integration_0_lambda_function_0:
+  aws:lambda_permission:rest_api_1_integration_0_lambda_function_0 -> aws:lambda_function:lambda_function_0:
+  aws:lambda_function:lambda_function_0 -> aws:SERVICE_API:lambda_function_0_lambda_function_0-log-group:
+  aws:lambda_function:lambda_function_0 -> aws:ecr_image:lambda_function_0-image:
+  aws:lambda_function:lambda_function_0 -> aws:iam_role:lambda_function_0-ExecutionRole:
+  aws:SERVICE_API:lambda_function_0_lambda_function_0-log-group -> aws:log_group:lambda_function_0-log-group:
+  aws:ecr_image:lambda_function_0-image -> aws:ecr_repo:ecr_repo-0:
+  aws:iam_role:lambda_function_0-ExecutionRole -> aws:log_group:lambda_function_0-log-group:
diff --git a/pkg/engine2/testdata/single_lambda.dataflow-viz.yaml b/pkg/engine2/testdata/single_lambda.dataflow-viz.yaml
new file mode 100755
index 00000000..45556b94
--- /dev/null
+++ b/pkg/engine2/testdata/single_lambda.dataflow-viz.yaml
@@ -0,0 +1,6 @@
+provider: aws
+resources:
+  lambda_function/lambda_function_0:
+    children: aws:ecr_image:lambda_function_0-image,aws:iam_role:lambda_function_0-ExecutionRole,aws:log_group:lambda_function_0-log-group
+
+
diff --git a/pkg/engine2/testdata/single_lambda.expect.yaml b/pkg/engine2/testdata/single_lambda.expect.yaml
new file mode 100755
index 00000000..703ad43e
--- /dev/null
+++ b/pkg/engine2/testdata/single_lambda.expect.yaml
@@ -0,0 +1,36 @@
+resources:
+    aws:lambda_function:lambda_function_0:
+        ExecutionRole: aws:iam_role:lambda_function_0-ExecutionRole
+        Image: aws:ecr_image:lambda_function_0-image
+        LogGroup: aws:log_group:lambda_function_0-log-group
+        MemorySize: 512
+        Timeout: 180
+    aws:SERVICE_API:lambda_function_0-lambda_function_0-log-group:
+    aws:ecr_image:lambda_function_0-image:
+        Context: .
+        Dockerfile: lambda_function_0-image.Dockerfile
+        Repo: aws:ecr_repo:ecr_repo-0
+    aws:iam_role:lambda_function_0-ExecutionRole:
+        AssumeRolePolicyDoc:
+            Statement:
+                - Action:
+                    - sts:AssumeRole
+                  Effect: Allow
+                  Principal:
+                    Service:
+                        - lambda.amazonaws.com
+            Version: "2012-10-17"
+        ManagedPolicies:
+            - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
+    aws:ecr_repo:ecr_repo-0:
+        ForceDelete: true
+    aws:log_group:lambda_function_0-log-group:
+        LogGroupName: /aws/lambda/lambda_function_0
+        RetentionInDays: 5
+edges:
+    aws:lambda_function:lambda_function_0 -> aws:SERVICE_API:lambda_function_0-lambda_function_0-log-group:
+    aws:lambda_function:lambda_function_0 -> aws:ecr_image:lambda_function_0-image:
+    aws:lambda_function:lambda_function_0 -> aws:iam_role:lambda_function_0-ExecutionRole:
+    aws:SERVICE_API:lambda_function_0-lambda_function_0-log-group -> aws:log_group:lambda_function_0-log-group:
+    aws:ecr_image:lambda_function_0-image -> aws:ecr_repo:ecr_repo-0:
+    aws:iam_role:lambda_function_0-ExecutionRole -> aws:log_group:lambda_function_0-log-group:
diff --git a/pkg/engine2/testdata/single_lambda.iac-viz.yaml b/pkg/engine2/testdata/single_lambda.iac-viz.yaml
new file mode 100755
index 00000000..f40b22d4
--- /dev/null
+++ b/pkg/engine2/testdata/single_lambda.iac-viz.yaml
@@ -0,0 +1,16 @@
+provider: aws
+resources:
+  ecr_repo/ecr_repo-0:
+
+  log_group/lambda_function_0-log-group:
+
+  ecr_image/lambda_function_0-image:
+  ecr_image/lambda_function_0-image -> ecr_repo/ecr_repo-0:
+
+  iam_role/lambda_function_0-executionrole:
+  iam_role/lambda_function_0-executionrole -> log_group/lambda_function_0-log-group:
+
+  lambda_function/lambda_function_0:
+  lambda_function/lambda_function_0 -> ecr_image/lambda_function_0-image:
+  lambda_function/lambda_function_0 -> iam_role/lambda_function_0-executionrole:
+
diff --git a/pkg/engine2/testdata/single_lambda.input.yaml b/pkg/engine2/testdata/single_lambda.input.yaml
new file mode 100644
index 00000000..9623c98f
--- /dev/null
+++ b/pkg/engine2/testdata/single_lambda.input.yaml
@@ -0,0 +1,4 @@
+constraints:
+  - node: aws:lambda_function:lambda_function_0
+    operator: add
+    scope: application