Repository: cycloidio/terracost (go)

Issue:
Support Terraform provider aliases
## Abstract

Currently, only the Terraform provider without an alias will work. Since in the case of AWS, the only way to define resources in multiple regions is to use provider aliases (with each provider config referencing a different region), this is needed in order to be able to estimate resources in multiple regions.

## Implementation details

The `provider_name` property from both `planned_values` and `prior_state` blocks reference the full name of a provider (e.g. `registry.terraform.io/hashicorp/aws`). From that point, there is no way of retrieving the actual provider config or even its alias. The only way is to cross-reference the `configuration` block - there the alias will be available under the `provider_config_key` property (e.g. "aws.alias")

Pull request:
terraform: support provider aliases
This PR fixes a bug due to which resources wouldn't be correctly estimated if using provider aliases. Closes #31 

Patch:
diff --git a/terraform/plan.go b/terraform/plan.go
index 207e163..70445b1 100644
--- a/terraform/plan.go
+++ b/terraform/plan.go
@@ -64,9 +64,6 @@ func (p *Plan) ExtractPriorQueries() ([]query.Resource, error) {
 func (p *Plan) extractProviders() (map[string]Provider, error) {
 	providers := make(map[string]Provider)
 	for name, provConfig := range p.Configuration.ProviderConfig {
-		if provConfig.Alias != "" {
-			continue
-		}
 		if pi, ok := p.providerInitializers[provConfig.Name]; ok {
 			values, err := p.evaluateProviderConfigExpressions(provConfig)
 			if err != nil {
@@ -76,9 +73,7 @@ func (p *Plan) extractProviders() (map[string]Provider, error) {
 			if err != nil {
 				return nil, err
 			}
-			for _, name := range pi.MatchNames {
-				providers[name] = prov
-			}
+			providers[name] = prov
 		}
 	}
 	return providers, nil
@@ -87,10 +82,22 @@ 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
+	// should be used to estimate it.
+	resToProvKey := make(map[string]string)
+	for _, res := range p.Configuration.RootModule.Resources {
+		resToProvKey[res.Address] = res.ProviderConfigKey
+	}
+
 	result := make([]query.Resource, 0)
 	for _, module := range modules {
 		for _, tfres := range module.Resources {
-			if provider, ok := providers[tfres.ProviderName]; ok {
+			providerKey, ok := resToProvKey[tfres.Address]
+			if !ok {
+				providerKey = tfres.ProviderName
+			}
+
+			if provider, ok := providers[providerKey]; ok {
 				comps := provider.ResourceComponents(tfres)
 				if comps != nil {
 					q := query.Resource{
diff --git a/terraform/schema.go b/terraform/schema.go
index 5f30966..6d3bb4d 100644
--- a/terraform/schema.go
+++ b/terraform/schema.go
@@ -36,9 +36,21 @@ type Module struct {
 // Configuration is a Terraform plan configuration.
 type Configuration struct {
 	ProviderConfig map[string]ProviderConfig `json:"provider_config"`
+	RootModule     ConfigurationModule       `json:"root_module"`
 }
 
 // Variable is a Terraform variable declaration.
 type Variable struct {
 	Value string `json:"value"`
 }
+
+// ConfigurationModule is used to configure a module.
+type ConfigurationModule struct {
+	Resources []ConfigurationResource `json:"resources"`
+}
+
+// ConfigurationResource is used to configure a single reosurce.
+type ConfigurationResource struct {
+	Address           string `json:"address"`
+	ProviderConfigKey string `json:"provider_config_key"`
+}


Test patch:
diff --git a/e2e/aws_estimation_test.go b/e2e/aws_estimation_test.go
index cff237f..32e6bb3 100644
--- a/e2e/aws_estimation_test.go
+++ b/e2e/aws_estimation_test.go
@@ -139,20 +139,30 @@ func TestAWSEstimation(t *testing.T) {
 		assertDecimalEqual(t, decimal.NewFromFloat(901.5), plan.PlannedCost())
 
 		diffs := plan.ResourceDifferences()
-		require.Len(t, diffs, 1)
-		assert.Equal(t, "aws_instance.example", diffs[0].Address)
-
-		compute := diffs[0].ComponentDiffs["Compute"]
-		require.NotNil(t, compute)
-		assert.Equal(t, []string{"Linux", "on-demand", "t2.micro"}, compute.Prior.Details)
-		assert.Equal(t, []string{"Linux", "on-demand", "t2.xlarge"}, compute.Planned.Details)
-		assertDecimalEqual(t, decimal.NewFromFloat(87.6), compute.PriorCost())
-		assertDecimalEqual(t, decimal.NewFromFloat(897.9), compute.PlannedCost())
-
-		rootVol := diffs[0].ComponentDiffs["Root volume: Storage"]
-		require.NotNil(t, rootVol)
-		assertDecimalEqual(t, decimal.NewFromFloat(3.6), rootVol.PriorCost())
-		assertDecimalEqual(t, decimal.NewFromFloat(3.6), rootVol.PlannedCost())
+		require.Len(t, diffs, 2)
+
+		for _, diff := range diffs {
+			switch diff.Address {
+			case "aws_instance.example":
+				compute := diff.ComponentDiffs["Compute"]
+				require.NotNil(t, compute)
+				assert.Equal(t, []string{"Linux", "on-demand", "t2.micro"}, compute.Prior.Details)
+				assert.Equal(t, []string{"Linux", "on-demand", "t2.xlarge"}, compute.Planned.Details)
+				assertDecimalEqual(t, decimal.NewFromFloat(87.6), compute.PriorCost())
+				assertDecimalEqual(t, decimal.NewFromFloat(897.9), compute.PlannedCost())
+
+				rootVol := diff.ComponentDiffs["Root volume: Storage"]
+				require.NotNil(t, rootVol)
+				assertDecimalEqual(t, decimal.NewFromFloat(3.6), rootVol.PriorCost())
+				assertDecimalEqual(t, decimal.NewFromFloat(3.6), rootVol.PlannedCost())
+
+			case "aws_lb.example":
+				lb := diff.ComponentDiffs["Application Load Balancer"]
+				require.NotNil(t, lb)
+				assert.False(t, diff.Valid())
+				assertDecimalEqual(t, decimal.NewFromFloat(0), lb.Planned.Cost())
+			}
+		}
 	})
 
 	t.Run("ProductNotFound", func(t *testing.T) {
diff --git a/terraform/plan_test.go b/terraform/plan_test.go
index 72adeba..55046a5 100644
--- a/terraform/plan_test.go
+++ b/terraform/plan_test.go
@@ -36,14 +36,19 @@ func TestPlan_ExtractPlannedQueries(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, "t2.xlarge", res.Values["instance_type"])
+			if res.Type == "aws_instance" {
+				assert.Equal(t, "aws_instance.example", res.Address)
+				assert.Equal(t, "t2.xlarge", res.Values["instance_type"])
+			} else {
+				assert.Equal(t, "aws_lb.example", res.Address)
+				assert.Equal(t, "application", res.Values["load_balancer_type"])
+			}
 			return []query.Component{}, nil
-		})
+		}).Times(2)
 
 		queries, err := plan.ExtractPlannedQueries()
 		require.NoError(t, err)
-		require.Len(t, queries, 1)
+		require.Len(t, queries, 2)
 	})
 
 	t.Run("BadProvider", func(t *testing.T) {
@@ -90,7 +95,7 @@ func TestPlan_ExtractPlannedQueries(t *testing.T) {
 
 		provider.EXPECT().ResourceComponents(gomock.Any()).DoAndReturn(func(res terraform.Resource) ([]query.Component, error) {
 			return nil, errors.New("ResourceComponents fail")
-		})
+		}).Times(2)
 
 		queries, err := plan.ExtractPlannedQueries()
 		require.NoError(t, err)
diff --git a/testdata/terraform-plan.json b/testdata/terraform-plan.json
index a173584..d55db52 100644
--- a/testdata/terraform-plan.json
+++ b/testdata/terraform-plan.json
@@ -1,6 +1,6 @@
 {
   "format_version": "0.1",
-  "terraform_version": "0.12.28",
+  "terraform_version": "0.14.9",
   "planned_values": {
     "root_module": {
       "resources": [
@@ -9,7 +9,7 @@
           "mode": "managed",
           "type": "aws_instance",
           "name": "example",
-          "provider_name": "aws",
+          "provider_name": "registry.terraform.io/hashicorp/aws",
           "schema_version": 1,
           "values": {
             "ami": "ami-2757f631",
@@ -24,6 +24,17 @@
             ],
             "tenancy": "default"
           }
+        },
+        {
+          "address": "aws_lb.example",
+          "mode": "managed",
+          "type": "aws_lb",
+          "name": "example",
+          "provider_name": "registry.terraform.io/hashicorp/aws",
+          "schema_version": 0,
+          "values": {
+            "load_balancer_type": "application"
+          }
         }
       ]
     }
@@ -67,6 +78,23 @@
         },
         "after_unknown": {}
       }
+    },
+    {
+      "address": "aws_lb.example",
+      "mode": "managed",
+      "type": "aws_lb",
+      "name": "example",
+      "provider_name": "registry.terraform.io/hashicorp/aws",
+      "change": {
+        "actions": [
+          "create"
+        ],
+        "before": null,
+        "after": {
+          "load_balancer_type": "application"
+        },
+        "after_unknown": {}
+      }
     }
   ],
   "prior_state": {
@@ -112,6 +140,17 @@
             "constant_value": "us-east-1"
           }
         }
+      },
+      "aws.paris": {
+        "name": "aws-test",
+        "expressions": {
+          "profile": {
+            "constant_value": "default"
+          },
+          "region": {
+            "constant_value": "eu-west-3"
+          }
+        }
       }
     },
     "root_module": {
@@ -131,6 +170,19 @@
             }
           },
           "schema_version": 1
+        },
+        {
+          "address": "aws_lb.example",
+          "mode": "managed",
+          "type": "aws_lb",
+          "name": "example",
+          "provider_config_key": "aws.paris",
+          "expressions": {
+            "load_balancer_type": {
+              "constant_value": "application"
+            }
+          },
+          "schema_version": 1
         }
       ]
     }