Repository: cycloidio/terracost (go)

Issue:
Expose `provider` and `type` for estimated resources
The `cost.Resource` and `cost.ResourceDiff` structs are missing any information about which provider they belong to and which type they are. Currently, the only way to extract this is from the address, however, this is being complicated by the fact that module resource addresses are different from root module ones and also by the effort that library consumers would require.

These two fields would be much better provided by us on the aforementioned types.

Pull request:
cost: expose `Provider` and `Type` of resources
This PR adds `Provider` and `Type` values to estimated resources. Closes #42 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ba0663..5727b38 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 ## [Unreleased]
 
+### Added
+
+- Expose `provider` and `type` fields for estimated resources
+  ([Issue #42](https://github.com/cycloidio/terracost/issues/42))
 
 ## [0.1.1] _2021-04-29_
 
diff --git a/aws/terraform/provider.go b/aws/terraform/provider.go
index fae713d..208faea 100644
--- a/aws/terraform/provider.go
+++ b/aws/terraform/provider.go
@@ -23,6 +23,9 @@ func NewProvider(key string, regionCode region.Code) (*Provider, error) {
 	return &Provider{key: key, region: regionCode}, nil
 }
 
+// Name returns the Provider's common name.
+func (p *Provider) Name() string { return p.key }
+
 // ResourceComponents returns Component queries for a given terraform.Resource.
 func (p *Provider) ResourceComponents(tfRes terraform.Resource) []query.Component {
 	switch tfRes.Type {
diff --git a/cost/plan.go b/cost/plan.go
index c3f2207..036abc5 100644
--- a/cost/plan.go
+++ b/cost/plan.go
@@ -89,6 +89,8 @@ func mergeResourceDiffsFromState(rdmap map[string]ResourceDiff, state *State, pl
 		if _, ok := rdmap[address]; !ok {
 			rdmap[address] = ResourceDiff{
 				Address:        address,
+				Provider:       res.Provider,
+				Type:           res.Type,
 				ComponentDiffs: make(map[string]*ComponentDiff),
 			}
 		}
diff --git a/cost/resource.go b/cost/resource.go
index 7096380..8e571de 100644
--- a/cost/resource.go
+++ b/cost/resource.go
@@ -3,6 +3,8 @@ package cost
 // Resource represents costs of a single cloud resource. Each Resource includes a Component map, keyed
 // by the label.
 type Resource struct {
+	Provider   string
+	Type       string
 	Components map[string]Component
 	Skipped    bool
 }
@@ -20,6 +22,8 @@ func (re Resource) Cost() Cost {
 // map, keyed by the label.
 type ResourceDiff struct {
 	Address        string
+	Provider       string
+	Type           string
 	ComponentDiffs map[string]*ComponentDiff
 }
 
diff --git a/cost/state.go b/cost/state.go
index c36c953..f1c6703 100644
--- a/cost/state.go
+++ b/cost/state.go
@@ -35,9 +35,7 @@ func NewState(ctx context.Context, backend Backend, queries []query.Resource) (*
 
 	for _, res := range queries {
 		// Mark the Resource as skipped if there are no valid Components.
-		if len(res.Components) == 0 {
-			state.Resources[res.Address] = Resource{Skipped: true}
-		}
+		state.ensureResource(res.Address, res.Provider, res.Type, len(res.Components) == 0)
 
 		for _, comp := range res.Components {
 			prods, err := backend.Product().Filter(ctx, comp.ProductFilter)
@@ -90,12 +88,24 @@ func (s *State) Cost() Cost {
 	return total
 }
 
-// addComponent adds the Component with given label to the Resource at given address. The Resource is created
-// if it doesn't already exist.
-func (s *State) addComponent(resAddress, compLabel string, component Component) {
-	if _, ok := s.Resources[resAddress]; !ok {
-		s.Resources[resAddress] = Resource{Components: make(map[string]Component)}
+// ensureResource creates Resource at the given address if it doesn't already exist.
+func (s *State) ensureResource(address, provider, typ string, skipped bool) {
+	if _, ok := s.Resources[address]; !ok {
+		res := Resource{
+			Provider: provider,
+			Type:     typ,
+			Skipped:  skipped,
+		}
+
+		if !skipped {
+			res.Components = make(map[string]Component)
+		}
+
+		s.Resources[address] = res
 	}
+}
 
+// addComponent adds the Component with given label to the Resource at given address.
+func (s *State) addComponent(resAddress, compLabel string, component Component) {
 	s.Resources[resAddress].Components[compLabel] = component
 }
diff --git a/mock/terraform_provider.go b/mock/terraform_provider.go
index 8e15f1d..4f3c356 100644
--- a/mock/terraform_provider.go
+++ b/mock/terraform_provider.go
@@ -35,6 +35,20 @@ func (m *TerraformProvider) EXPECT() *TerraformProviderMockRecorder {
 	return m.recorder
 }
 
+// Name mocks base method
+func (m *TerraformProvider) Name() string {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Name")
+	ret0, _ := ret[0].(string)
+	return ret0
+}
+
+// Name indicates an expected call of Name
+func (mr *TerraformProviderMockRecorder) Name() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*TerraformProvider)(nil).Name))
+}
+
 // ResourceComponents mocks base method
 func (m *TerraformProvider) ResourceComponents(arg0 terraform.Resource) []query.Component {
 	m.ctrl.T.Helper()
diff --git a/query/query.go b/query/query.go
index bdcc27a..434a1bd 100644
--- a/query/query.go
+++ b/query/query.go
@@ -13,6 +13,12 @@ type Resource struct {
 	// Address uniquely identifies this cloud Resource.
 	Address string
 
+	// Provider is the cloud provider that this Resource belongs to.
+	Provider string
+
+	// Type describes the type of the Resource.
+	Type string
+
 	// Components is a list of price components that make up this Resource. If it is empty, the resource
 	// is considered to be skipped.
 	Components []Component
diff --git a/terraform/plan.go b/terraform/plan.go
index c5bf634..c650d4a 100644
--- a/terraform/plan.go
+++ b/terraform/plan.go
@@ -140,6 +140,8 @@ func (p *Plan) extractModuleQueries(module *Module, resourceProviders map[string
 		comps := provider.ResourceComponents(tfres)
 		q := query.Resource{
 			Address:    tfres.Address,
+			Provider:   provider.Name(),
+			Type:       tfres.Type,
 			Components: comps,
 		}
 		result = append(result, q)
diff --git a/terraform/provider.go b/terraform/provider.go
index 7db9ddd..53f3fbd 100644
--- a/terraform/provider.go
+++ b/terraform/provider.go
@@ -8,6 +8,9 @@ import (
 
 // Provider represents a Terraform provider. It extracts price queries from Terraform resources.
 type Provider interface {
+	// Name returns the common name of this Provider.
+	Name() string
+
 	// ResourceComponents returns price component queries for the given Resource. Nil may be returned
 	// which signifies a resource that is not supported by this Provider.
 	ResourceComponents(res Resource) []query.Component


Test patch:
diff --git a/terraform/plan_test.go b/terraform/plan_test.go
index 7f3322e..0c49acd 100644
--- a/terraform/plan_test.go
+++ b/terraform/plan_test.go
@@ -35,6 +35,7 @@ func TestPlan_ExtractPlannedQueries(t *testing.T) {
 		err = plan.Read(f)
 		require.NoError(t, err)
 
+		provider.EXPECT().Name().AnyTimes().Return("aws-test")
 		provider.EXPECT().ResourceComponents(gomock.Any()).DoAndReturn(func(res terraform.Resource) ([]query.Component, error) {
 			if res.Type == "aws_instance" {
 				assert.Equal(t, "module.instance.aws_instance.example", res.Address)
@@ -93,6 +94,7 @@ func TestPlan_ExtractPlannedQueries(t *testing.T) {
 		err = plan.Read(f)
 		require.NoError(t, err)
 
+		provider.EXPECT().Name().AnyTimes().Return("aws-test")
 		provider.EXPECT().ResourceComponents(gomock.Any()).DoAndReturn(func(res terraform.Resource) ([]query.Component, error) {
 			return nil, errors.New("ResourceComponents fail")
 		}).Times(2)
@@ -100,7 +102,11 @@ 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: "module.instance.aws_instance.example"})
+		assert.Contains(t, queries, query.Resource{
+			Address:  "module.instance.aws_instance.example",
+			Provider: "aws-test",
+			Type:     "aws_instance",
+		})
 	})
 }
 
@@ -125,6 +131,7 @@ func TestPlan_ExtractPriorQueries(t *testing.T) {
 		err = plan.Read(f)
 		require.NoError(t, err)
 
+		provider.EXPECT().Name().AnyTimes().Return("aws-test")
 		provider.EXPECT().ResourceComponents(gomock.Any()).DoAndReturn(func(res terraform.Resource) ([]query.Component, error) {
 			assert.Equal(t, "module.instance.aws_instance.example", res.Address)
 			assert.Equal(t, "t2.micro", res.Values["instance_type"])
@@ -178,6 +185,7 @@ func TestPlan_ExtractPriorQueries(t *testing.T) {
 		err = plan.Read(f)
 		require.NoError(t, err)
 
+		provider.EXPECT().Name().AnyTimes().Return("aws-test")
 		provider.EXPECT().ResourceComponents(gomock.Any()).DoAndReturn(func(res terraform.Resource) ([]query.Component, error) {
 			return nil, errors.New("ResourceComponents fail")
 		})
@@ -185,6 +193,10 @@ 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: "module.instance.aws_instance.example"})
+		assert.Contains(t, queries, query.Resource{
+			Address:  "module.instance.aws_instance.example",
+			Provider: "aws-test",
+			Type:     "aws_instance",
+		})
 	})
 }