Repository: klothoplatform/klotho
Pull request #667 (2023-06-08 13:47:56)

be able to load and export resource dag
• Does any part of it require special attention?
• Does it relate to or fix any issue? closes https://github.com/klothoplatform/klotho-pro/issues/88

The ability to load a BaseConstruct graph by calling all the providers load graph method.

Theres also a loadConstructs method for our higher level constructs

We also need to ensure that namespaced resources get loaded and added after their parent resource so we use reflection to call a load method (only subnets at the moment)

We will also output the graph as resources.yaml on compilation

### Standard checks

- **Unit tests**: Any special considerations?
- **Docs**: Do we need to update any docs, internal or public?
- **Backwards compatibility**: Will this break existing apps? If so, what would be the extra work required to keep them working?



Patch:
diff --git a/pkg/cli/klothomain.go b/pkg/cli/klothomain.go
index f5ebf051..ea162b51 100644
--- a/pkg/cli/klothomain.go
+++ b/pkg/cli/klothomain.go
@@ -400,36 +400,32 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) {
 		return err
 	}
 
-	constructs := core.NewConstructGraph()
-
-	if cfg.constructGraph != "" {
-		plugins.AnalysisAndTransform = nil
-		createdGraph, err := core.CreateConstructGraphFromFile(cfg.constructGraph)
-		if err != nil {
-			return err
-		}
-		constructs = createdGraph
-	}
-
 	document := compiler.CompilationDocument{
 		InputFiles:       input,
 		FileDependencies: &core.FileDependencies{},
-		Constructs:       constructs,
+		Constructs:       core.NewConstructGraph(),
 		Configuration:    &appCfg,
 		Resources:        core.NewResourceGraph(),
 		OutputOptions:    options.Output,
 	}
 
-	compiler := compiler.Compiler{
+	klothoCompiler := compiler.Compiler{
 		AnalysisAndTransformationPlugins: plugins.AnalysisAndTransform,
 		ProviderPlugins:                  plugins.Provider,
 		IaCPlugins:                       plugins.IaC,
 		Document:                         document,
 	}
 
+	if cfg.constructGraph != "" {
+		err = klothoCompiler.LoadConstructGraphFromFile(cfg.constructGraph)
+		if err != nil {
+			return err
+		}
+	}
+
 	analyticsClient.Info(klothoName + " compiling")
 
-	err = compiler.Compile()
+	err = klothoCompiler.Compile()
 	if err != nil || hadErrors.Load() {
 		if err != nil {
 			errHandler.PrintErr(err)
diff --git a/pkg/compiler/compilation_document.go b/pkg/compiler/compilation_document.go
index 945559b4..16fd0733 100644
--- a/pkg/compiler/compilation_document.go
+++ b/pkg/compiler/compilation_document.go
@@ -18,6 +18,7 @@ import (
 	"github.com/klothoplatform/klotho/pkg/multierr"
 	"github.com/pkg/errors"
 	"go.uber.org/zap"
+	"gopkg.in/yaml.v2"
 )
 
 type (
@@ -190,6 +191,39 @@ func (document *CompilationDocument) OutputResources() (resourceCounts map[strin
 	return
 }
 
+func (document *CompilationDocument) OutputGraph(outDir string) error {
+	err := os.MkdirAll(outDir, 0777)
+	if err != nil {
+		return err
+	}
+	var merr multierr.Error
+
+	f, err := os.Create(path.Join(outDir, "resources.yaml"))
+	if err != nil {
+		return errors.Wrap(err, "error creating resource dump")
+	} else {
+		defer f.Close()
+		enc := yaml.NewEncoder(f)
+
+		outputGraph := core.OutputGraph{}
+		for _, res := range document.Resources.ListResources() {
+			outputGraph.Resources = append(outputGraph.Resources, res.Id())
+		}
+
+		for _, dep := range document.Resources.ListDependencies() {
+			outputGraph.Edges = append(outputGraph.Edges, core.OutputEdge{
+				Source:      dep.Source.Id(),
+				Destination: dep.Destination.Id(),
+			})
+		}
+		err = enc.Encode(outputGraph)
+		if err != nil {
+			merr.Append(errors.Wrap(err, "error writing resources"))
+		}
+	}
+	return merr.ErrOrNil()
+}
+
 func (document *CompilationDocument) OutputHelpers(outDir string) error {
 	var merr multierr.Error
 	for _, resource := range document.Resources.ListResources() {
diff --git a/pkg/compiler/compiler.go b/pkg/compiler/compiler.go
index d202f27b..a555ac00 100644
--- a/pkg/compiler/compiler.go
+++ b/pkg/compiler/compiler.go
@@ -3,6 +3,7 @@ package compiler
 import (
 	"bytes"
 	"fmt"
+	"os"
 
 	"github.com/klothoplatform/klotho/pkg/annotation"
 	"github.com/klothoplatform/klotho/pkg/config"
@@ -10,6 +11,7 @@ import (
 	"github.com/klothoplatform/klotho/pkg/validation"
 	"github.com/pkg/errors"
 	"go.uber.org/zap"
+	"gopkg.in/yaml.v2"
 )
 
 type (
@@ -26,6 +28,7 @@ type (
 	ProviderPlugin interface {
 		Plugin
 		Translate(result *core.ConstructGraph, dag *core.ResourceGraph) error
+		LoadGraph(graph core.OutputGraph, dag *core.ConstructGraph) error
 	}
 
 	IaCPlugin interface {
@@ -104,6 +107,10 @@ func (c *Compiler) Compile() error {
 		}
 		c.Document.OutputFiles = append(c.Document.OutputFiles, files...)
 	}
+	err = c.Document.OutputGraph(c.Document.Configuration.OutDir)
+	if err != nil {
+		return errors.Wrap(err, "Unable to output graph")
+	}
 	err = c.createConfigOutputFile()
 	if err != nil {
 		return errors.Wrap(err, "Unable to output Klotho configuration file")
@@ -124,3 +131,32 @@ func (c *Compiler) createConfigOutputFile() error {
 	})
 	return nil
 }
+
+func (c *Compiler) LoadConstructGraphFromFile(path string) error {
+
+	input := core.OutputGraph{}
+	f, err := os.Open(path)
+	if err != nil {
+		return err
+	}
+	defer f.Close() // nolint:errcheck
+
+	err = yaml.NewDecoder(f).Decode(&input)
+	if err != nil {
+		return err
+	}
+
+	err = core.LoadConstructsIntoGraph(input, c.Document.Constructs)
+	if err != nil {
+		return err
+	}
+
+	c.AnalysisAndTransformationPlugins = nil
+	for _, provider := range c.ProviderPlugins {
+		err := provider.LoadGraph(input, c.Document.Constructs)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/pkg/core/construct_graph.go b/pkg/core/construct_graph.go
index 9f11d093..07991e7c 100644
--- a/pkg/core/construct_graph.go
+++ b/pkg/core/construct_graph.go
@@ -1,19 +1,24 @@
 package core
 
 import (
-	"os"
-
 	"github.com/klothoplatform/klotho/pkg/annotation"
 	"github.com/klothoplatform/klotho/pkg/graph"
-	"github.com/pkg/errors"
 	"go.uber.org/zap"
-	"gopkg.in/yaml.v2"
 )
 
 type (
 	ConstructGraph struct {
 		underlying *graph.Directed[BaseConstruct]
 	}
+
+	OutputEdge struct {
+		Source      ResourceId `yaml:"source"`
+		Destination ResourceId `yaml:"destination"`
+	}
+	OutputGraph struct {
+		Resources []ResourceId `yaml:"resources"`
+		Edges     []OutputEdge `yaml:"edges"`
+	}
 )
 
 func NewConstructGraph() *ConstructGraph {
@@ -129,93 +134,45 @@ func (cg *ConstructGraph) FindUpstreamGateways(unit *ExecutionUnit) []*Gateway {
 	return gateways
 }
 
-func CreateConstructGraphFromFile(path string) (*ConstructGraph, error) {
-
-	type ConstructRepresentation struct {
-		Kind string `yaml:"kind"`
-	}
-
-	type EdgeRepresentation struct {
-		Source      string `yaml:"source"`
-		Destination string `yaml:"destination"`
-	}
-
-	type ConstructGraphRepresentation struct {
-		Nodes map[string]ConstructRepresentation `yaml:"nodes"`
-		Edges []EdgeRepresentation               `yaml:"edges"`
-	}
-
-	graph := NewConstructGraph()
-	input := ConstructGraphRepresentation{
-		Nodes: map[string]ConstructRepresentation{},
-	}
-	keys := map[string]AnnotationKey{}
-
-	f, err := os.Open(path)
-	if err != nil {
-		return graph, err
-	}
-	defer f.Close() // nolint:errcheck
+func LoadConstructsIntoGraph(input OutputGraph, graph *ConstructGraph) error {
 
-	err = yaml.NewDecoder(f).Decode(&input)
-	if err != nil {
-		return graph, err
-	}
-
-	for id, construct := range input.Nodes {
-		switch construct.Kind {
-		case "execution_unit":
-			unit := &ExecutionUnit{AnnotationKey: AnnotationKey{ID: id, Capability: annotation.ExecutionUnitCapability}}
-			graph.AddConstruct(unit)
-			keys[id] = unit.AnnotationKey
-		case "static_unit":
-			unit := &StaticUnit{AnnotationKey: AnnotationKey{ID: id, Capability: annotation.StaticUnitCapability}}
-			graph.AddConstruct(unit)
-			keys[id] = unit.AnnotationKey
-		case "orm":
-			unit := &Orm{AnnotationKey: AnnotationKey{ID: id, Capability: annotation.PersistCapability}}
-			graph.AddConstruct(unit)
-			keys[id] = unit.AnnotationKey
-		case "kv":
-			unit := &Kv{AnnotationKey: AnnotationKey{ID: id, Capability: annotation.PersistCapability}}
-			graph.AddConstruct(unit)
-			keys[id] = unit.AnnotationKey
-		case "redis_node":
-			unit := &RedisNode{AnnotationKey: AnnotationKey{ID: id, Capability: annotation.PersistCapability}}
-			graph.AddConstruct(unit)
-			keys[id] = unit.AnnotationKey
-		case "fs":
-			unit := &Fs{AnnotationKey: AnnotationKey{ID: id, Capability: annotation.PersistCapability}}
-			graph.AddConstruct(unit)
-			keys[id] = unit.AnnotationKey
-		case "secret":
-			unit := &Config{AnnotationKey: AnnotationKey{ID: id, Capability: annotation.PersistCapability}, Secret: true}
-			graph.AddConstruct(unit)
-			keys[id] = unit.AnnotationKey
-		case "expose":
-			unit := &Gateway{AnnotationKey: AnnotationKey{ID: id, Capability: annotation.ExposeCapability}}
-			graph.AddConstruct(unit)
-			keys[id] = unit.AnnotationKey
-		default:
-			return graph, errors.Errorf("Unsupported kind %s in construct graph creation", construct.Kind)
-		}
+	for _, res := range input.Resources {
+		graph.AddConstruct(getConstructFromInputId(res))
 	}
 
 	for _, edge := range input.Edges {
-		src, dst := keys[edge.Source], keys[edge.Destination]
-		graph.AddDependency(
-			ResourceId{
-				Provider: AbstractConstructProvider,
-				Type:     src.Capability,
-				Name:     src.ID,
-			},
-			ResourceId{
-				Provider: AbstractConstructProvider,
-				Type:     dst.Capability,
-				Name:     dst.ID,
-			},
-		)
+		graph.AddDependency(getConstructFromInputId(edge.Source).Id(), getConstructFromInputId(edge.Destination).Id())
 	}
 
-	return graph, err
+	return nil
+}
+
+func getConstructFromInputId(res ResourceId) Construct {
+	switch res.Type {
+	case "execution_unit":
+		unit := &ExecutionUnit{AnnotationKey: AnnotationKey{ID: res.Name, Capability: annotation.ExecutionUnitCapability}}
+		return unit
+	case "static_unit":
+		unit := &StaticUnit{AnnotationKey: AnnotationKey{ID: res.Name, Capability: annotation.StaticUnitCapability}}
+		return unit
+	case "orm":
+		unit := &Orm{AnnotationKey: AnnotationKey{ID: res.Name, Capability: annotation.PersistCapability}}
+		return unit
+	case "kv":
+		unit := &Kv{AnnotationKey: AnnotationKey{ID: res.Name, Capability: annotation.PersistCapability}}
+		return unit
+	case "redis_node":
+		unit := &RedisNode{AnnotationKey: AnnotationKey{ID: res.Name, Capability: annotation.PersistCapability}}
+		return unit
+	case "fs":
+		unit := &Fs{AnnotationKey: AnnotationKey{ID: res.Name, Capability: annotation.PersistCapability}}
+		return unit
+	case "secret":
+		unit := &Config{AnnotationKey: AnnotationKey{ID: res.Name, Capability: annotation.ConfigCapability}, Secret: true}
+		return unit
+	case "expose":
+		unit := &Gateway{AnnotationKey: AnnotationKey{ID: res.Name, Capability: annotation.ExposeCapability}}
+		return unit
+	}
+	return nil
 }
diff --git a/pkg/core/resource_graph.go b/pkg/core/resource_graph.go
index 88404afb..ef53b4bd 100644
--- a/pkg/core/resource_graph.go
+++ b/pkg/core/resource_graph.go
@@ -62,11 +62,15 @@ func (rg *ResourceGraph) AddDependencyWithData(deployedSecond Resource, deployed
 	}
 	rg.AddResource(deployedSecond)
 	rg.AddResource(deployedFirst)
-	if cycle, _ := rg.underlying.CreatesCycle(deployedSecond.Id().String(), deployedFirst.Id().String()); cycle {
-		zap.S().Errorf("Not Adding Dependency, Cycle would be created from edge %s -> %s", deployedSecond.Id(), deployedFirst.Id())
+	rg.AddDependencyById(deployedSecond.Id(), deployedFirst.Id(), data)
+}
+
+func (rg *ResourceGraph) AddDependencyById(deployedSecond ResourceId, deployedFirst ResourceId, data any) {
+	if cycle, _ := rg.underlying.CreatesCycle(deployedSecond.String(), deployedFirst.String()); cycle {
+		zap.S().Errorf("Not Adding Dependency, Cycle would be created from edge %s -> %s", deployedSecond, deployedFirst)
 	} else {
-		rg.underlying.AddEdge(deployedSecond.Id().String(), deployedFirst.Id().String(), data)
-		zap.S().Debugf("adding %s -> %s", deployedSecond.Id(), deployedFirst.Id())
+		rg.underlying.AddEdge(deployedSecond.String(), deployedFirst.String(), data)
+		zap.S().Debugf("adding %s -> %s", deployedSecond, deployedFirst)
 	}
 }
 
diff --git a/pkg/core/resources.go b/pkg/core/resources.go
index 5a7be988..e4f36986 100644
--- a/pkg/core/resources.go
+++ b/pkg/core/resources.go
@@ -41,13 +41,13 @@ type (
 	}
 
 	ResourceId struct {
-		Provider string
-		Type     string
+		Provider string `yaml:"provider" toml:"provider"`
+		Type     string `yaml:"type" toml:"type"`
 		// Namespace is optional and is used to disambiguate resources that might have
 		// the same name. It can also be used to associate an imported resource with
 		// a specific namespace such as a subnet to a VPC.
-		Namespace string
-		Name      string
+		Namespace string `yaml:"namespace" toml:"namespace"`
+		Name      string `yaml:"name" toml:"name"`
 	}
 
 	// IaCValue is a struct that defines a value we need to grab from a specific resource. It is up to the plugins to make the determination of how to retrieve the value
diff --git a/pkg/infra/kubernetes/plugin.go b/pkg/infra/kubernetes/plugin.go
index 4b297989..f643e8f0 100644
--- a/pkg/infra/kubernetes/plugin.go
+++ b/pkg/infra/kubernetes/plugin.go
@@ -28,6 +28,10 @@ type Kubernetes struct {
 	helmHelper *helm.HelmHelper
 }
 
+func (p Kubernetes) LoadGraph(graph core.OutputGraph, dag *core.ConstructGraph) error {
+	return nil
+}
+
 func (p Kubernetes) Name() string { return "kubernetes" }
 
 func (p Kubernetes) Translate(constructGraph *core.ConstructGraph, dag *core.ResourceGraph) error {
diff --git a/pkg/provider/aws/provider.go b/pkg/provider/aws/provider.go
index 9a1bc26e..94fa5f40 100644
--- a/pkg/provider/aws/provider.go
+++ b/pkg/provider/aws/provider.go
@@ -1,11 +1,15 @@
 package aws
 
 import (
+	"errors"
+	"fmt"
+	"reflect"
 	"sort"
 
 	"github.com/klothoplatform/klotho/pkg/config"
 	"github.com/klothoplatform/klotho/pkg/core"
 	knowledgebase "github.com/klothoplatform/klotho/pkg/knowledge_base"
+	"github.com/klothoplatform/klotho/pkg/provider/aws/resources"
 )
 
 type AWS struct {
@@ -44,6 +48,87 @@ func (a *AWS) MapResourceDirectlyToConstruct(resource core.Resource, construct c
 }
 
 func (a *AWS) GetResourcesDirectlyTiedToConstruct(construct core.BaseConstruct) ([]core.Resource, bool) {
-	resources, found := a.constructIdToResources[construct.Id()]
-	return resources, found
+	constructsResources, found := a.constructIdToResources[construct.Id()]
+	return constructsResources, found
+}
+
+func (a *AWS) LoadGraph(graph core.OutputGraph, dag *core.ConstructGraph) error {
+	typeToResource := make(map[string]core.Resource)
+	namespacedResources := make(map[string][]core.Resource)
+	createdResources := make(map[core.ResourceId]core.Resource)
+	for _, res := range resources.ListAll() {
+		typeToResource[res.Id().Type] = res
+	}
+	// Subnets are special because they have a type that is not the same as their resource type since it uses a characteristic of the subnet
+	typeToResource["subnet_private"] = &resources.Subnet{}
+	typeToResource["subnet_public"] = &resources.Subnet{}
+	var joinedErr error
+	for _, node := range graph.Resources {
+		res, ok := typeToResource[node.Type]
+		if !ok {
+			joinedErr = errors.Join(joinedErr, fmt.Errorf("unable to find resource of type %s", node.Type))
+			continue
+		}
+		newResource := reflect.New(reflect.TypeOf(res).Elem()).Interface()
+		resource, ok := newResource.(core.Resource)
+		if !ok {
+			joinedErr = errors.Join(joinedErr, fmt.Errorf("item %s of type %T is not of type core.Resource", node, newResource))
+			continue
+		}
+		reflect.ValueOf(resource).Elem().FieldByName("Name").SetString(node.Name)
+		if subnet, ok := resource.(*resources.Subnet); ok {
+			if node.Type == "subnet_public" {
+				subnet.Type = resources.PublicSubnet
+			} else if node.Type == "subnet_private" {
+				subnet.Type = resources.PrivateSubnet
+			}
+		}
+		if node.Namespace != "" {
+			namespacedResources[node.Namespace] = append(namespacedResources[node.Namespace], resource)
+			createdResources[node] = resource
+			continue
+		}
+
+		dag.AddConstruct(resource)
+		createdResources[node] = resource
+	}
+
+	// For anything namespaced, we will call the Load Method with the namespace and dag as the argument.
+	// This will allow the resource to be loaded into the graph since its id relies on the namespaced object
+	for namespace, namespaceResources := range namespacedResources {
+		for _, res := range namespaceResources {
+			method := reflect.ValueOf(res).MethodByName("Load")
+			if method.IsValid() {
+				var callArgs []reflect.Value
+				callArgs = append(callArgs, reflect.ValueOf(namespace))
+				callArgs = append(callArgs, reflect.ValueOf(dag))
+				eval := method.Call(callArgs)
+				if !eval[0].IsNil() {
+					err, ok := eval[0].Interface().(error)
+					if !ok {
+						joinedErr = errors.Join(joinedErr, fmt.Errorf("return type should be an error"))
+						continue
+					}
+					joinedErr = errors.Join(joinedErr, err)
+					continue
+				}
+			}
+			dag.AddConstruct(res)
+		}
+	}
+
+	for _, edge := range graph.Edges {
+		src, found := createdResources[edge.Source]
+		if !found {
+			joinedErr = errors.Join(joinedErr, fmt.Errorf("could not find created resource for %s", edge.Source))
+			continue
+		}
+		dst, found := createdResources[edge.Destination]
+		if !found {
+			joinedErr = errors.Join(joinedErr, fmt.Errorf("could not find created resource for %s", edge.Destination))
+			continue
+		}
+		dag.AddDependency(src.Id(), dst.Id())
+	}
+	return joinedErr
 }
diff --git a/pkg/provider/aws/resources/vpc.go b/pkg/provider/aws/resources/vpc.go
index 146542d6..da85ac2c 100644
--- a/pkg/provider/aws/resources/vpc.go
+++ b/pkg/provider/aws/resources/vpc.go
@@ -640,6 +640,20 @@ func (subnet *Subnet) Id() core.ResourceId {
 	return id
 }
 
+func (subnet *Subnet) Load(namespace string, dag *core.ConstructGraph) error {
+	namespacedVpc := &Vpc{Name: namespace}
+	vpc := dag.GetConstruct(namespacedVpc.Id())
+	if vpc == nil {
+		return fmt.Errorf("cannot load subnet with name %s because namespace vpc %s does not exist", subnet.Name, namespace)
+	}
+	if vpc, ok := vpc.(*Vpc); !ok {
+		return fmt.Errorf("cannot load subnet with name %s because namespace vpc %s is not a vpc", subnet.Name, namespace)
+	} else {
+		subnet.Vpc = vpc
+	}
+	return nil
+}
+
 func (subnet *Subnet) SetTypeFromId(id core.ResourceId) error {
 	if id.Provider != AWS_PROVIDER || !strings.HasPrefix(id.Type, VPC_SUBNET_TYPE_PREFIX) {
 		return fmt.Errorf("invalid id '%s' for partial subnet '%s'", id, subnet.Name)
diff --git a/pkg/provider/imports/plugin.go b/pkg/provider/imports/plugin.go
index 11e18894..5c175c8e 100644
--- a/pkg/provider/imports/plugin.go
+++ b/pkg/provider/imports/plugin.go
@@ -30,3 +30,7 @@ func (p Plugin) Translate(result *core.ConstructGraph, dag *core.ResourceGraph)
 	}
 	return nil
 }
+
+func (p Plugin) LoadGraph(graph core.OutputGraph, dag *core.ConstructGraph) error {
+	return nil
+}


Test patch:
diff --git a/pkg/core/construct_graph_test.go b/pkg/core/construct_graph_test.go
index 5b236306..bcda4db7 100644
--- a/pkg/core/construct_graph_test.go
+++ b/pkg/core/construct_graph_test.go
@@ -3,6 +3,7 @@ package core
 import (
 	"testing"
 
+	dgraph "github.com/dominikbraun/graph"
 	"github.com/klothoplatform/klotho/pkg/annotation"
 	"github.com/klothoplatform/klotho/pkg/graph"
 	"github.com/stretchr/testify/assert"
@@ -296,6 +297,80 @@ func Test_GetResourcesOfCapability(t *testing.T) {
 
 }
 
+func Test_LoadConstructsIntoGraph(t *testing.T) {
+	type result struct {
+		nodes []BaseConstruct
+		edges []graph.Edge[BaseConstruct]
+	}
+	tests := []struct {
+		name       string
+		constructs OutputGraph
+		want       result
+	}{
+		{
+			name: "single construct",
+			constructs: OutputGraph{
+				Resources: []ResourceId{
+					{Provider: AbstractConstructProvider, Type: "execution_unit", Name: "test_eu"},
+					{Provider: AbstractConstructProvider, Type: "static_unit", Name: "test_static"},
+					{Provider: AbstractConstructProvider, Type: "expose", Name: "test_expose"},
+					{Provider: AbstractConstructProvider, Type: "orm", Name: "test_orm"},
+					{Provider: AbstractConstructProvider, Type: "kv", Name: "test_kv"},
+					{Provider: AbstractConstructProvider, Type: "fs", Name: "test_fs"},
+					{Provider: AbstractConstructProvider, Type: "secret", Name: "test_secret"},
+					{Provider: AbstractConstructProvider, Type: "redis_node", Name: "test_redis"},
+				},
+				Edges: []OutputEdge{
+					{
+						Source:      ResourceId{Provider: AbstractConstructProvider, Type: "execution_unit", Name: "test_eu"},
+						Destination: ResourceId{Provider: AbstractConstructProvider, Type: "redis_node", Name: "test_redis"},
+					},
+					{
+						Source:      ResourceId{Provider: AbstractConstructProvider, Type: "expose", Name: "test_expose"},
+						Destination: ResourceId{Provider: AbstractConstructProvider, Type: "execution_unit", Name: "test_eu"},
+					},
+				},
+			},
+			want: result{
+				nodes: []BaseConstruct{
+					&ExecutionUnit{AnnotationKey: AnnotationKey{ID: "test_eu", Capability: annotation.ExecutionUnitCapability}},
+					&StaticUnit{AnnotationKey: AnnotationKey{ID: "test_static", Capability: annotation.StaticUnitCapability}},
+					&Gateway{AnnotationKey: AnnotationKey{ID: "test_expose", Capability: annotation.ExposeCapability}},
+					&Orm{AnnotationKey: AnnotationKey{ID: "test_orm", Capability: annotation.PersistCapability}},
+					&Kv{AnnotationKey: AnnotationKey{ID: "test_kv", Capability: annotation.PersistCapability}},
+					&Fs{AnnotationKey: AnnotationKey{ID: "test_fs", Capability: annotation.PersistCapability}},
+					&RedisNode{AnnotationKey: AnnotationKey{ID: "test_redis", Capability: annotation.PersistCapability}},
+					&Config{AnnotationKey: AnnotationKey{ID: "test_secret", Capability: annotation.ConfigCapability}, Secret: true},
+				},
+				edges: []graph.Edge[BaseConstruct]{
+					{
+						Source:      &ExecutionUnit{AnnotationKey: AnnotationKey{ID: "test_eu", Capability: annotation.ExecutionUnitCapability}},
+						Destination: &RedisNode{AnnotationKey: AnnotationKey{ID: "test_redis", Capability: annotation.PersistCapability}},
+						Properties:  dgraph.EdgeProperties{Attributes: map[string]string{}},
+					},
+					{
+						Source:      &Gateway{AnnotationKey: AnnotationKey{ID: "test_expose", Capability: annotation.ExposeCapability}},
+						Destination: &ExecutionUnit{AnnotationKey: AnnotationKey{ID: "test_eu", Capability: annotation.ExecutionUnitCapability}},
+						Properties:  dgraph.EdgeProperties{Attributes: map[string]string{}},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert := assert.New(t)
+			graph := NewConstructGraph()
+			err := LoadConstructsIntoGraph(tt.constructs, graph)
+			if !assert.NoError(err) {
+				return
+			}
+			assert.ElementsMatch(tt.want.nodes, ListConstructs[BaseConstruct](graph))
+			assert.ElementsMatch(tt.want.edges, graph.ListDependencies())
+		})
+	}
+}
+
 func construct2Hash(c BaseConstruct) string {
 	return c.Id().String()
 }
diff --git a/pkg/core/coretesting/resources.go b/pkg/core/coretesting/resources.go
index a646a2ee..74a534b3 100644
--- a/pkg/core/coretesting/resources.go
+++ b/pkg/core/coretesting/resources.go
@@ -38,6 +38,18 @@ func (expect ResourcesExpectation) Assert(t *testing.T, dag *core.ResourceGraph)
 	}
 }
 
+func (expect ResourcesExpectation) AssertConstructs(t *testing.T, graph *core.ConstructGraph) {
+	got := ConstructsFromGraph(graph)
+
+	if expect.AssertSubset {
+		assert.Subset(t, got.Nodes, expect.Nodes)
+		assert.Subset(t, got.Deps, expect.Deps)
+	} else {
+		expect.ElementsMatchPretty(t, expect.Nodes, got.Nodes)
+		expect.ElementsMatchPretty(t, expect.Deps, got.Deps)
+	}
+}
+
 // ElementsMatchPretty invokes [assert.ElementsMatch], but first does a string-based check based on the elements;
 // string representation. This means in the common case that unequal strings are enough to demonstrate inequality, we'll
 // get a nicer diff.
@@ -81,6 +93,22 @@ func ResoucesFromDAG(dag *core.ResourceGraph) ResourcesExpectation {
 	}
 }
 
+func ConstructsFromGraph(graph *core.ConstructGraph) ResourcesExpectation {
+	var nodes []string
+	for _, r := range core.ListConstructs[core.BaseConstruct](graph) {
+		nodes = append(nodes, r.Id().String())
+	}
+	var deps []StringDep
+	for _, e := range graph.ListDependencies() {
+		deps = append(deps, StringDep{Source: e.Source.Id().String(), Destination: e.Destination.Id().String()})
+	}
+
+	return ResourcesExpectation{
+		Nodes: nodes,
+		Deps:  deps,
+	}
+}
+
 // GoString is useful in combination with `ResoucesFromDAG` to generate or update unit tests. Make sure to read over
 // the results before using to make sure it is correct.
 // For example:
diff --git a/pkg/provider/aws/provider_test.go b/pkg/provider/aws/provider_test.go
index 9aa40b56..89a6bca8 100644
--- a/pkg/provider/aws/provider_test.go
+++ b/pkg/provider/aws/provider_test.go
@@ -4,6 +4,7 @@ import (
 	"testing"
 
 	"github.com/klothoplatform/klotho/pkg/core"
+	"github.com/klothoplatform/klotho/pkg/core/coretesting"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -50,6 +51,56 @@ func TestAwsMapResourceDirectlyToConstruct(t *testing.T) {
 	})
 }
 
+func Test_LoadGraph(t *testing.T) {
+	tests := []struct {
+		name       string
+		constructs core.OutputGraph
+		want       coretesting.ResourcesExpectation
+	}{
+		{
+			name: "single construct",
+			constructs: core.OutputGraph{
+				Resources: []core.ResourceId{
+					{Provider: "aws", Type: "vpc", Name: "vpc"},
+					{Provider: "aws", Type: "subnet_private", Name: "spriv", Namespace: "vpc"},
+					{Provider: "aws", Type: "subnet_public", Name: "spub", Namespace: "vpc"},
+					{Provider: "aws", Type: "lambda_function", Name: "test_func"},
+					{Provider: "aws", Type: "lambda_function", Name: "test_func2"},
+				},
+				Edges: []core.OutputEdge{
+					{
+						Source:      core.ResourceId{Provider: "aws", Type: "lambda_function", Name: "test_func"},
+						Destination: core.ResourceId{Provider: "aws", Type: "subnet_private", Name: "spriv", Namespace: "vpc"},
+					},
+				},
+			},
+			want: coretesting.ResourcesExpectation{
+				Nodes: []string{
+					"aws:subnet_private:vpc:spriv",
+					"aws:subnet_public:vpc:spub",
+					"aws:lambda_function:test_func",
+					"aws:lambda_function:test_func2",
+					"aws:vpc:vpc",
+				},
+				Deps: []coretesting.StringDep{
+					{Source: "aws:lambda_function:test_func", Destination: "aws:subnet_private:vpc:spriv"},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			graph := core.NewConstructGraph()
+			aws := &AWS{}
+			err := aws.LoadGraph(tt.constructs, graph)
+			if !assert.NoError(t, err) {
+				return
+			}
+			tt.want.AssertConstructs(t, graph)
+		})
+	}
+}
+
 type (
 	dummyResource  string
 	dummyConstruct string
diff --git a/pkg/provider/provider_test.go b/pkg/provider/provider_test.go
index 5aadbe67..6d3d1f20 100644
--- a/pkg/provider/provider_test.go
+++ b/pkg/provider/provider_test.go
@@ -83,6 +83,9 @@ func (p TestProvider) GetDefaultConfig() config.Defaults {
 	return config.Defaults{}
 
 }
+func (p TestProvider) LoadGraph(graph core.OutputGraph, dag *core.ConstructGraph) error {
+	return nil
+}
 func (a TestProvider) GetKindTypeMappings(construct core.Construct) []string {
 	switch construct.(type) {
 	case *core.ExecutionUnit: