Repository: cycloidio/terracost (go)

Issue:
terraform: support for child modules
According to the [Terraform JSON output format](https://www.terraform.io/docs/internals/json-format.html#values-representation) any module (including the root module) may contain child modules that describe their own resources. The module tree is described recursively.

Currently, no module other than the root module will be estimated as the `child_modules` property is ignored by the Terraform plan reader.

Pull request description:
terraform: add child module support
This PR adds the child module support to the Terraform plan schema. It splits the `Plan.extractQueries` method into two: 

- `extractModuleConfiguration` - fills the `resourceProviders` map by recursively calling itself for each descendant of the root module
- `extractModuleQueries` - returns a list of resource queries by recursively calling itself for each descendant of the root module

Closes #37.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 85a5c16..2c0a557 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,3 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Fixed
+
+- Add support for child modules in Terraform plan files
+  ([Issue #37](https://github.com/cycloidio/terracost/issues/37))
diff --git a/terraform/plan.go b/terraform/plan.go
index cceab22..c5bf634 100644
--- a/terraform/plan.go
+++ b/terraform/plan.go
@@ -15,7 +15,7 @@ type Plan struct {
 
 	Configuration Configuration       `json:"configuration"`
 	PriorState    *State              `json:"prior_state"`
-	PlannedValues map[string]Module   `json:"planned_values"`
+	PlannedValues Values              `json:"planned_values"`
 	Variables     map[string]Variable `json:"variables"`
 }
 
@@ -81,36 +81,74 @@ func (p *Plan) extractProviders() (map[string]Provider, error) {
 
 // extractQueries iterates over every resource and passes each to the corresponding Provider to get the components.
 // These are used to form a slice of resource queries that are then returned back to the caller.
-func (p *Plan) extractQueries(modules map[string]Module, providers map[string]Provider) []query.Resource {
-	// Create a map to associate each resource with a key of the provider that
+func (p *Plan) extractQueries(values Values, providers map[string]Provider) []query.Resource {
+	// Create a map to associate each resource with a Provider that
 	// should be used to estimate it.
-	resToProvKey := make(map[string]string)
-	for _, res := range p.Configuration.RootModule.Resources {
-		resToProvKey[res.Address] = res.ProviderConfigKey
+	resourceProviders := make(map[string]Provider)
+	p.extractModuleConfiguration("", &p.Configuration.RootModule, providers, resourceProviders)
+	return p.extractModuleQueries(&values.RootModule, resourceProviders)
+}
+
+// extractModuleConfiguration iterates over all the modules included in the plan's configuration block and
+// extracts the provider that should be used for each resource. This function calls itself recursively until
+// data from the entire module tree is extracted. It takes the following arguments:
+//   - prefix - the current module's address. Empty string signifies the root module.
+//   - module - the module's configuration block itself.
+//   - providers - map of provider name to Provider.
+//   - resourceProviders - used as an output of this function, it's a map of resource addresses to their assigned
+//     Provider. This map should be passed empty and not nil.
+func (p *Plan) extractModuleConfiguration(prefix string, module *ConfigurationModule, providers map[string]Provider, resourceProviders map[string]Provider) {
+	for _, res := range module.Resources {
+		key := res.ProviderConfigKey
+		if strings.Contains(key, ":") {
+			parts := strings.Split(key, ":")
+			key = parts[len(parts)-1]
+		}
+
+		addr := res.Address
+		if prefix != "" {
+			addr = fmt.Sprintf("module.%s.%s", prefix, addr)
+		}
+
+		if prov, ok := providers[key]; ok {
+			resourceProviders[addr] = prov
+		}
 	}
 
-	result := make([]query.Resource, 0)
-	for _, module := range modules {
-		for _, tfres := range module.Resources {
-			if tfres.Mode != "managed" {
-				continue
+	for k, child := range module.ModuleCalls {
+		if child.Module != nil {
+			nextPrefix := k
+			if prefix != "" {
+				nextPrefix = fmt.Sprintf("%s.%s", prefix, k)
 			}
+			p.extractModuleConfiguration(nextPrefix, child.Module, providers, resourceProviders)
+		}
+	}
+}
 
-			providerKey, ok := resToProvKey[tfres.Address]
-			if !ok {
-				providerKey = tfres.ProviderName
-			}
+// extractModuleQueries iterates recursively over all the module's (and its descendants) resources. It uses the
+// resourceProviders map to retrieve the correct Provider based on the resource address.
+func (p *Plan) extractModuleQueries(module *Module, resourceProviders map[string]Provider) []query.Resource {
+	result := make([]query.Resource, 0, len(resourceProviders))
 
-			if provider, ok := providers[providerKey]; ok {
-				comps := provider.ResourceComponents(tfres)
-				q := query.Resource{
-					Address:    tfres.Address,
-					Components: comps,
-				}
-				result = append(result, q)
-			}
+	for _, tfres := range module.Resources {
+		provider, ok := resourceProviders[tfres.Address]
+		if !ok || tfres.Mode != "managed" {
+			continue
 		}
+
+		comps := provider.ResourceComponents(tfres)
+		q := query.Resource{
+			Address:    tfres.Address,
+			Components: comps,
+		}
+		result = append(result, q)
+	}
+
+	for _, child := range module.ChildModules {
+		result = append(result, p.extractModuleQueries(child, resourceProviders)...)
 	}
+
 	return result
 }
 
diff --git a/terraform/schema.go b/terraform/schema.go
index 4f8e94e..e2e76c0 100644
--- a/terraform/schema.go
+++ b/terraform/schema.go
@@ -13,9 +13,14 @@ type ProviderConfig struct {
 	Expressions map[string]ProviderConfigExpression `json:"expressions"`
 }
 
+// Values is a tree of modules and resources within.
+type Values struct {
+	RootModule Module `json:"root_module"`
+}
+
 // State is a collection of resource modules.
 type State struct {
-	Values map[string]Module `json:"values"`
+	Values Values `json:"values"`
 }
 
 // Resource is a single Terraform resource definition.
@@ -31,7 +36,9 @@ type Resource struct {
 
 // Module is a collection of resources.
 type Module struct {
-	Resources []Resource `json:"resources"`
+	Address      string     `json:"address"`
+	Resources    []Resource `json:"resources"`
+	ChildModules []*Module  `json:"child_modules"`
 }
 
 // Configuration is a Terraform plan configuration.
@@ -47,7 +54,10 @@ type Variable struct {
 
 // ConfigurationModule is used to configure a module.
 type ConfigurationModule struct {
-	Resources []ConfigurationResource `json:"resources"`
+	Resources   []ConfigurationResource `json:"resources"`
+	ModuleCalls map[string]struct {
+		Module *ConfigurationModule `json:"module"`
+	} `json:"module_calls"`
 }
 
 // ConfigurationResource is used to configure a single reosurce.


Test patch:
diff --git a/terraform/plan_test.go b/terraform/plan_test.go
index 512701e..7f3322e 100644
--- a/terraform/plan_test.go
+++ b/terraform/plan_test.go
@@ -37,7 +37,7 @@ func TestPlan_ExtractPlannedQueries(t *testing.T) {
 
 		provider.EXPECT().ResourceComponents(gomock.Any()).DoAndReturn(func(res terraform.Resource) ([]query.Component, error) {
 			if res.Type == "aws_instance" {
-				assert.Equal(t, "aws_instance.example", res.Address)
+				assert.Equal(t, "module.instance.aws_instance.example", res.Address)
 				assert.Equal(t, "t2.xlarge", res.Values["instance_type"])
 			} else {
 				assert.Equal(t, "aws_lb.example", res.Address)
@@ -100,7 +100,7 @@ func TestPlan_ExtractPlannedQueries(t *testing.T) {
 		queries, err := plan.ExtractPlannedQueries()
 		require.NoError(t, err)
 		require.Len(t, queries, 2)
-		assert.Contains(t, queries, query.Resource{Address: "aws_instance.example"})
+		assert.Contains(t, queries, query.Resource{Address: "module.instance.aws_instance.example"})
 	})
 }
 
@@ -126,7 +126,7 @@ func TestPlan_ExtractPriorQueries(t *testing.T) {
 		require.NoError(t, err)
 
 		provider.EXPECT().ResourceComponents(gomock.Any()).DoAndReturn(func(res terraform.Resource) ([]query.Component, error) {
-			assert.Equal(t, "aws_instance.example", res.Address)
+			assert.Equal(t, "module.instance.aws_instance.example", res.Address)
 			assert.Equal(t, "t2.micro", res.Values["instance_type"])
 			return []query.Component{}, nil
 		})
@@ -185,6 +185,6 @@ func TestPlan_ExtractPriorQueries(t *testing.T) {
 		queries, err := plan.ExtractPriorQueries()
 		require.NoError(t, err)
 		require.Len(t, queries, 1)
-		assert.Contains(t, queries, query.Resource{Address: "aws_instance.example"})
+		assert.Contains(t, queries, query.Resource{Address: "module.instance.aws_instance.example"})
 	})
 }
diff --git a/testdata/terraform-plan.json b/testdata/terraform-plan.json
index d55db52..32311fb 100644
--- a/testdata/terraform-plan.json
+++ b/testdata/terraform-plan.json
@@ -3,28 +3,35 @@
   "terraform_version": "0.14.9",
   "planned_values": {
     "root_module": {
-      "resources": [
+      "child_modules": [
         {
-          "address": "aws_instance.example",
-          "mode": "managed",
-          "type": "aws_instance",
-          "name": "example",
-          "provider_name": "registry.terraform.io/hashicorp/aws",
-          "schema_version": 1,
-          "values": {
-            "ami": "ami-2757f631",
-            "availability_zone": "us-east-1e",
-            "instance_type": "t2.xlarge",
-            "root_block_device": [
-              {
-                "iops": 100,
-                "volume_size": 8,
-                "volume_type": "gp2"
+          "resources": [
+            {
+              "address": "module.instance.aws_instance.example",
+              "mode": "managed",
+              "type": "aws_instance",
+              "name": "example",
+              "provider_name": "registry.terraform.io/hashicorp/aws",
+              "schema_version": 1,
+              "values": {
+                "ami": "ami-2757f631",
+                "availability_zone": "us-east-1e",
+                "instance_type": "t2.xlarge",
+                "root_block_device": [
+                  {
+                    "iops": 100,
+                    "volume_size": 8,
+                    "volume_type": "gp2"
+                  }
+                ],
+                "tenancy": "default"
               }
-            ],
-            "tenancy": "default"
-          }
-        },
+            }
+          ],
+          "address": "module.instance"
+        }
+      ],
+      "resources": [
         {
           "address": "aws_lb.example",
           "mode": "managed",
@@ -41,7 +48,8 @@
   },
   "resource_changes": [
     {
-      "address": "aws_instance.example",
+      "address": "module.instance.aws_instance.example",
+      "module_address": "module.instance",
       "mode": "managed",
       "type": "aws_instance",
       "name": "example",
@@ -102,27 +110,32 @@
     "terraform_version": "0.12.28",
     "values": {
       "root_module": {
-        "resources": [
+        "child_modules": [
           {
-            "address": "aws_instance.example",
-            "mode": "managed",
-            "type": "aws_instance",
-            "name": "example",
-            "provider_name": "aws",
-            "schema_version": 1,
-            "values": {
-              "ami": "ami-2757f631",
-              "availability_zone": "us-east-1e",
-              "instance_type": "t2.micro",
-              "root_block_device": [
-                {
-                  "iops": 100,
-                  "volume_size": 8,
-                  "volume_type": "gp2"
+            "resources": [
+              {
+                "address": "module.instance.aws_instance.example",
+                "mode": "managed",
+                "type": "aws_instance",
+                "name": "example",
+                "provider_name": "aws",
+                "schema_version": 1,
+                "values": {
+                  "ami": "ami-2757f631",
+                  "availability_zone": "us-east-1e",
+                  "instance_type": "t2.micro",
+                  "root_block_device": [
+                    {
+                      "iops": 100,
+                      "volume_size": 8,
+                      "volume_type": "gp2"
+                    }
+                  ],
+                  "tenancy": "default"
                 }
-              ],
-              "tenancy": "default"
-            }
+              }
+            ],
+            "address": "module.instance"
           }
         ]
       }
@@ -154,23 +167,32 @@
       }
     },
     "root_module": {
+      "module_calls": {
+        "instance": {
+          "source": "./instance",
+          "module": {
+            "resources": [
+              {
+                "address": "aws_instance.example",
+                "mode": "managed",
+                "type": "aws_instance",
+                "name": "example",
+                "provider_config_key": "instance:aws",
+                "expressions": {
+                  "ami": {
+                    "constant_value": "ami-2757f631"
+                  },
+                  "instance_type": {
+                    "constant_value": "t2.xlarge"
+                  }
+                },
+                "schema_version": 1
+              }
+            ]
+          }
+        }
+      },
       "resources": [
-        {
-          "address": "aws_instance.example",
-          "mode": "managed",
-          "type": "aws_instance",
-          "name": "example",
-          "provider_config_key": "aws",
-          "expressions": {
-            "ami": {
-              "constant_value": "ami-2757f631"
-            },
-            "instance_type": {
-              "constant_value": "t2.xlarge"
-            }
-          },
-          "schema_version": 1
-        },
         {
           "address": "aws_lb.example",
           "mode": "managed",