-
Notifications
You must be signed in to change notification settings - Fork 32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
AWS estimation #9
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So a few things:
- Does TF have a way to read Plans? I say this just for the sake of maintainability, they change the API quite often and if you have to check it one by one it'll be a nightmare, so if they have already an implementation maybe would be good to use it (IDK if they do though, or if it's usable).
- I see that on the
Provider.New*
you make a manual assignation,tenancyVal, _ := values["tenancy"].(string)
and then assign and then validate, could be better to justjson.Unmarshal
it directly, so you don't have to care about empty/casting errors. - Also on the same line of TF, I would try to get a more up to date State as we are already on
TF 0.13.5
and things may have changed on the way there.
terraform/plan.go
Outdated
// NewPlan returns an empty Plan. | ||
func NewPlan(opts ...Option) *Plan { | ||
plan := &Plan{providerInitializers: make(map[string]ProviderInitializer)} | ||
for _, opt := range opts { | ||
opt(plan) | ||
} | ||
return plan | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A Plan
without providerInitializers
is useless no? I'm saying this because you should validate it, and also if the only configuration is the providerInitializers
then maybe it's better to have it as a (pi ...ProviderInitializer)
and have the ProviderInitializer
have a pi.Key/Name/Type
which is basically the name.
I would not let the user do a WithProvider("aw", aws.PI)
as the aws.PI
will ALWAYS need to have the "aws"
no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reason was that there might be multiple names for the same provider implementation, e.g. google
and google-beta
and also that the ProviderInitializer
was just a type alias for a func, so it cannot provide its name. However, I see now that it would be indeed better to have it as (pi ...ProviderInitializer)
with ProviderInitializer
being a struct with a slice of provider names that will match it.
func (p *Plan) Read(r io.Reader) error { | ||
if err := json.NewDecoder(r).Decode(p); err != nil { | ||
return err | ||
} | ||
return nil | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does TF have a way to load Plans?
I'm saying this because if they change formats on the future you'll not know and will have to manually maintain it, but if they have it they maintain it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As mentioned in the other comment, they have but it's unexported 😅
terraform/plan.go
Outdated
return p.extractQueries(p.PriorState.Values, providers) | ||
} | ||
|
||
func (p *Plan) extractProviders() map[string]Provider { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Docs
return providers | ||
} | ||
|
||
func (p *Plan) extractQueries(modules map[string]Module, providers map[string]Provider) []query.Resource { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Docs
terraform/provider.go
Outdated
// ProviderName returns the name of the Provider. | ||
ProviderName() string | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that Provierd.ProviderName
is a bit redundant, should just be Name
.
aws/terraform/instance.go
Outdated
// NewInstance creates a new Instance from Terraform values. | ||
func (p *Provider) NewInstance(values map[string]interface{}) *Instance { | ||
instType, _ := values["instance_type"].(string) | ||
tenancyVal, _ := values["tenancy"].(string) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is "optional" on the tfstate, If it's not defined it may cause a panic which it's not dealt with.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AFAIK if the double-value-return form of the type assertion is used (foo, ok := bar.(string)
) then it won't cause a panic but instead the ok
will be false
. In this case, we don't care about the ok
, but rely on the variable to hold the zero-value in case of an assertion failure.
aws/terraform/instance.go
Outdated
func (p *Provider) NewInstance(values map[string]interface{}) *Instance { | ||
instType, _ := values["instance_type"].(string) | ||
tenancyVal, _ := values["tenancy"].(string) | ||
zone, _ := values["availability_zone"].(string) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is "optional" on the tfstate, If it's not defined it may cause a panic which it's not dealt with.
aws/terraform/provider.go
Outdated
// Provider is an implementation of the terraform.Provider, used to extract component queries from | ||
// terraform resources. | ||
type Provider struct { | ||
name string | ||
region string | ||
} | ||
|
||
// NewProvider returns a new Provider. | ||
func NewProvider(name string, region string) *Provider { | ||
return &Provider{name: name, region: region} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the name
? This is inside aws/
already, what do you expect as name?
I think those 2 have to be validated, as they are used afterwards as if they are always set, for example the region
is always set based on this Provider.region
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, the name will not be needed anymore (as it will be provided by the ProviderInitializer
) and I will validate the region
to match the list of regions that we already have.
aws/terraform/volume.go
Outdated
volType, _ := values["type"].(string) | ||
volSize, _ := values["size"].(float64) | ||
volIops, _ := values["iops"].(float64) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All this 3 are optional.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The volume type too?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it's "gp2" by default
e2e/testdata/terraform-plan.json
Outdated
{ | ||
"format_version": "0.1", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this the same plan you added before? If so just use the same one :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wanted to keep them separate as they belong to two different test suites (e2e/testdata
and terraform/testdata
). But I suppose this can be moved to a testdata
dir at the root and be used by both? 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The question is more: are those two likely to evolve differently, if the answer is no then let's merge them, if so then let's keep them separated.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think they'll evolve differently, so putting them in a single place might make more sense
Unfortunately not. They define a plan JSON format here but the
Do you mean having a struct into which the JSON would be unmarshalled? I don't think his would work as at the time of the unmarshal we don't know what the resource is or even to which provider it belongs. So we need to unmarshal each resource into a
It seems that the JSON representation of the plan is still on version 0.1 and haven't changed yet. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Various comments done, I think an important part will be the documentation associated with the implementation to make sure it's understandable and thorough regarding what it can/cannot do, whys, etc.
aws/terraform/instance.go
Outdated
|
||
operatingSystem string | ||
capacityStatus string | ||
preInstalledSW string |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While understanding the other, I'm having trouble knowing what this is for - perhaps some comments would help on those fields for people less familiar with AWS/Terraform?
} | ||
|
||
// Valid returns true if the region exists and is supported, false otherwise. | ||
func (c Code) Valid() bool { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As Code
can also be ""
it might be worth checking that ahead of iterating?
aws/terraform/instance.go
Outdated
} | ||
if volParams == nil { | ||
// Default EBS Volume values | ||
volParams = map[string]interface{}{"availability_zone": zone} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can be the initialization then, removing the need to check for volParams == nil
as it would be override afterwards if need be
aws/terraform_provider.go
Outdated
"github.com/cycloidio/cost-estimation/terraform" | ||
) | ||
|
||
var TerraformQueryKey = "aws" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure about the compiler behaviour, but can be directly a const
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, it's var
so that I can override it for the tests, it seemed to be the simplest way
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need this?
IDK why it should be overrided from the tests, this is aws/
pkg already this should not change no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, but I override it in e2e
so that it doesn't conflict with any entries that had been already ingested before. E.g. if real AWS pricing data was ingested, the test might fetch it, breaking the result.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't quite like this, doing something due to tests, maybe the code is not right then, should this be a paramter then?
And for what I see, the arguemtn is that the e2e test may break actual exported data? That is like saying that you have a constant on the code for the DB and you have to change it if not you remove production data, then that constant it's a variable I think.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If it's a parameter, it would never be used by real consumers, since they'd always use only the aws
value so it'd clutter the public API, I believe. Yes, it could break real data since the test could possibly add an entry with the same properties as a real entry, as well as real data could possibly break a test (if only 1 entry is expected, but there is another, "real" one.) The estimation might give wrong results if two SKU's are found with the same properties.
So I'm not sure what'd be the best way to do it. I could add a QueryKey
field to the ProviderInitializer
, for example, and then have the TerraformProviderInitializer.QueryKey
to be changed from the test (like the MatchNames
field already is) but that just moves the problem into another global variable (the global singleton TerraformProviderInitializer
struct.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Couldn't we make it another singleton or something? Initialise it with a certain value if provided else fallback to this one?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mean a separate struct that holds the query key as a field? 🤔 I believe that would just move the problem into a struct, instead of dealing with it, usage would stay the same as with a global variable. Or did I misunderstand what you mean?
} | ||
|
||
// State represents a collection of all the Resource costs (either prior or planned.) | ||
type State struct { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I assume this is supposed to be a tfstate? No more tf way to get it / exported from TF?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not a TF state, but rather an intermediate representation of a state of resources. This struct could possibly be generated by using tools different than TF (if we get to that in the future.) A State
here is like a snapshot (could this be a better name?) of resources at a given time, and two states (prior and planned) form a Plan
that can be estimated.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I'm not sure whats the best wording for that. Perhaps improving slightly the comments would do?
cost/state.go
Outdated
rate := prices[0].Value | ||
if quantity.Equals(decimal.Zero) { | ||
quantity = comp.HourlyQuantity | ||
rate = rate.Mul(decimal.NewFromInt(730)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
730?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Number of hours in a month, to convert an hourly rate to a monthly one, will add a doc
e2e/testdata/terraform-plan.json
Outdated
{ | ||
"format_version": "0.1", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The question is more: are those two likely to evolve differently, if the answer is no then let's merge them, if so then let's keep them separated.
Pushed fixups for most of the comments (except the ones awaiting feedback) |
Ok I meant this https://github.com/mitchellh/mapstructure
I can tell you that they have changed it some things on the State, as I had to make changes for version I'll take a look to the new commits. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some general things but I would also try to use golint
to solve some of the missing comments. Also there are some missing tests :)
aws/terraform_provider.go
Outdated
"github.com/cycloidio/cost-estimation/terraform" | ||
) | ||
|
||
var TerraformQueryKey = "aws" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need this?
IDK why it should be overrided from the tests, this is aws/
pkg already this should not change no?
terraform/plan.go
Outdated
"io" | ||
|
||
"github.com/cycloidio/cost-estimation/query" | ||
) | ||
|
||
// Plan is a representation of a Terraform plan file. | ||
type Plan struct { | ||
providerInitializers map[string]ProviderInitializer | ||
providerInitializers []ProviderInitializer |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nah, IMO the ProviderInitializer
should have a whay for you to know the Provider.
So then you can have a map[string]ProviderInitializer
where the key is the Provider.Type/Name
It makes things much more easy on the rest of the code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reason was that a single provider might match multiple names (such as google
and google-beta
, for example), so every ProviderInitializer
has a MatchNames
slice instead. Could it be better to have the same initializer under multiple keys in the map[string]ProviderInitializer
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, so then it's an easy acces and you can also validate that 2 names are not colliding.
Pushed fixups addressing all the comments.
Updated to use this library, it's much better now, thanks.
Do you mean the JSON output format? 🤔 I will check if there are any differences.
I've run golint after the fixups and all of the files added by this PR pass now. There are still issues with files that already existed but these can be dealt with in a dedicated PR. |
they changed some things on the State, I found it on my tests for TC update to TF 0.13.5. IDK if the plan has changed though. But not much we can do if they do not export it. Commented so some of the things but now looks better |
Is this OK to merge now or does it require some more work?
I checked and apparently the JSON structure hasn't changed for 2 years, so it should be safe 😄 |
Taking a look now |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Only minor things remaining, but the documentation to come will have to be really thorough to document everything commented in here, the current scope and limitation of our tool (services, type within the service, etc).
aws/terraform_provider.go
Outdated
"github.com/cycloidio/cost-estimation/terraform" | ||
) | ||
|
||
var TerraformQueryKey = "aws" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Couldn't we make it another singleton or something? Initialise it with a certain value if provided else fallback to this one?
LGTM RS |
9b38835
to
a5a051e
Compare
Rebased |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall I think it's all fine, just this small ones.
I'll check after this is merge more in the global structure/architecture, but I think it's fine.
func NewPlan(providerInitializers ...ProviderInitializer) *Plan { | ||
piMap := make(map[string]ProviderInitializer) | ||
for _, pi := range providerInitializers { | ||
for _, name := range pi.MatchNames { | ||
piMap[name] = pi | ||
} | ||
} | ||
plan := &Plan{providerInitializers: piMap} | ||
return plan | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would make sense to validat len(providerInitializers)
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not exactly sure - I wouldn't say that it's an "error" to have no providers configured. The Plan
would always return a zero estimate, but that's expected (no input equals no output.) Also, this is a lower-level struct and func that is not supposed to be used by library consumers - the EstimateTerraformPlan
func is and it validates providerInitializers
. Does it make sense?
aws/region/code.go
Outdated
func (c Code) Valid() bool { | ||
if c == "" { | ||
return false | ||
} | ||
for _, code := range regionMap { | ||
if c == code { | ||
return true | ||
} | ||
} | ||
return false | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that to make things "easier" I would call on func init(){}
and generate a reverse map of the regionMap
so we can search for both ways easy.
aws/terraform/instance.go
Outdated
// capacityStatus describes the status of capacity reservations. | ||
// Valid values include: Used, UnusedCapacityReservation, AllocatedCapacityReservation. | ||
// Note: only "Used" is supported at the moment. | ||
capacityStatus string // valid values: Used, UnusedCapacityReservation, AllocatedCapacityReservation |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can remove this comment as it's already on the docs.
aws/terraform_provider.go
Outdated
|
||
// TerraformProviderInitializer is a terraform.ProviderInitializer that initializes the default AWS provider. | ||
var TerraformProviderInitializer = terraform.ProviderInitializer{ | ||
MatchNames: []string{"aws"}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not TerraformQueryKey
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reason was that the names from MatchNames
and from TerraformQueryKey
are actually two different things technically: TerraformQueryKey
is the name under which the pricing data was saved in the DB, while MatchNames
includes a list of names that Terraform recognizes. These may be different (e.g. for GCP, there are two providers: google
and google-beta
, while there'd be only one query key).
However, in the case of AWS they're all the same, including the fact that TerraformQueryKey
is a duplicate of the existing ProviderName
😅 - so I will use that in all cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, yes Azure also have a few providers.
return rds | ||
} | ||
|
||
func mergeResourceDiffsFromState(rdmap map[string]ResourceDiff, state *State, planned bool) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
docs
estimation.go
Outdated
// EstimateTerraformPlan is a helper function that reads a Terraform plan using the provided io.Reader, | ||
// generates the prior and planned cost.State, and then creates a cost.Plan from them that is returned. | ||
// It uses the Backend to retrieve the pricing data. | ||
func EstimateTerraformPlan(ctx context.Context, backend Backend, r io.Reader, providerInitializers ...terraform.ProviderInitializer) (*cost.Plan, error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe for clarity I would s/r io.Reader/ plan io.Reader/
having it just be r
it's not clear, it requires you to actually read the docs or it's not possible, just changing that I can "easily" know what it needs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mostly minor changes on test cases. The rest looks good to me!
queries, err := plan.ExtractPriorQueries() | ||
require.NoError(t, err) | ||
require.Len(t, queries, 1) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd add cases for failures too, just to ensure an error stops the execution / is returned
return total | ||
} | ||
|
||
func (s *State) addComponent(resAddress, compLabel string, component Component) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
docs
cost/state_test.go
Outdated
|
||
state, err := cost.NewState(ctx, backend, queries) | ||
require.NoError(t, err) | ||
assert.Equal(t, expected, state) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add cases of failures too
e2e/aws_estimation_test.go
Outdated
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()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cases of failures too, doesn't have to make the other calls fail voluntarily but just ensuring all errors are handled
Pushed fixups that should resolve all the comments |
LGTM RS when @xlr-8 says |
Seems like the tests of failure cases were added, were they? |
The purpose of the terraform package is to read a Terraform plan file and extract query.Resource records. These will be subsequently used to estimate the plan cost.
Define a Provider that implements the terraform.Provider interface and provide a NewTerraformProvider func that matches the signature of the terraform.ProviderInitializer type. This allows it to be used as one of the providers during plan reading process. The Provider returns a query.Component slice for each terraform.Resource that uses the Provider.
The purpose of the cost package is to retrieve pricing data for a particular State (list of resources) and to merge two States to achieve a Plan - containing cost differences for all resources included in both or either of the states.
ea6b5aa
to
fe8303b
Compare
Rebased |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM :)
This PR adds the functionality to estimate Terraform plan costs using the pricing data stored in the DB.
It adds the following packages:
terraform
- contains the logic of reading a Terraform plan and generating a list ofquery.Component
s found in there. This is done by passing Terraform resource values to registered providers (e.g.aws/terraform
package) that fetch the pricing using a passed Backendaws/terraform
- contains rules for transformingterraform.Resource
s intoquery.Component
s (onlyaws_instance
andaws_ebs_volume
in this PR)cost
- calculate costs of aState
(collection of resources) and aPlan
(combination of a "prior" and "planned"State
)Usage to calculate prior and planned costs of every resource in a plan: