-
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
terraform: estimation of HCL module directory #45
Changes from all commits
5583ecf
ba0d9c7
e70bd07
b18cbd0
6ad4a2c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,273 @@ | ||
package terraform | ||
|
||
import ( | ||
"fmt" | ||
"path" | ||
|
||
"github.com/hashicorp/hcl/v2" | ||
"github.com/hashicorp/hcl/v2/hclsyntax" | ||
"github.com/hashicorp/terraform/configs" | ||
"github.com/spf13/afero" | ||
"github.com/zclconf/go-cty/cty" | ||
|
||
"github.com/cycloidio/terracost/query" | ||
) | ||
|
||
// ExtractQueriesFromHCL returns the resources found in the module identified by the modPath. | ||
func ExtractQueriesFromHCL(fs afero.Fs, providerInitializers []ProviderInitializer, modPath string) ([]query.Resource, error) { | ||
parser := configs.NewParser(fs) | ||
mod, diags := parser.LoadConfigDir(modPath) | ||
if diags.HasErrors() { | ||
return nil, fmt.Errorf(diags.Error()) | ||
} | ||
|
||
evalCtx := getEvalCtx(mod, nil) | ||
|
||
providers, err := getHCLProviders(mod, evalCtx, providerInitializers) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
return extractHCLModule(providers, parser, modPath, "", mod, evalCtx) | ||
} | ||
|
||
// extractHCLModule returns the resources found in the provided module. | ||
func extractHCLModule(providers map[string]Provider, parser *configs.Parser, modPath string, modName string, mod *configs.Module, evalCtx *hcl.EvalContext) ([]query.Resource, error) { | ||
queries := make([]query.Resource, 0, len(mod.ManagedResources)) | ||
|
||
for rk, rv := range mod.ManagedResources { | ||
if modName != "" { | ||
rk = fmt.Sprintf("%s.%s", modName, rk) | ||
} | ||
|
||
providerKey := rv.Provider.Type | ||
if rv.ProviderConfigRef != nil { | ||
providerKey = rv.ProviderConfigRef.String() | ||
} | ||
provider := providers[providerKey] | ||
|
||
// Parse the HCL body of the resource block and evaluate it. The JSON (in the form of map[string]interface{} type) | ||
// is then placed into the cfg. | ||
body, ok := rv.Config.(*hclsyntax.Body) | ||
if !ok { | ||
return nil, fmt.Errorf("invalid resource configuration body") | ||
} | ||
cfg := getBodyJSON(body, evalCtx) | ||
|
||
// Assume this is a single instance of this resource unless the cfg contains the "count" parameter. | ||
count := 1 | ||
if c, ok := cfg["count"]; ok { | ||
if cf, ok := c.(float64); ok { | ||
count = int(cf) | ||
} | ||
} | ||
|
||
for i := 0; i < count; i++ { | ||
addr := rk | ||
if count > 1 { | ||
addr = fmt.Sprintf("%s[%d]", rk, i) | ||
} | ||
|
||
// Only retrieve components if the provider is valid. If it's not, the comps will be nil, which signifies | ||
// that the resource was "skipped" from estimation. | ||
var comps []query.Component | ||
if provider != nil { | ||
comps = provider.ResourceComponents(Resource{ | ||
Address: addr, | ||
Index: i, | ||
Mode: "managed", | ||
Type: rv.Type, | ||
Name: rv.Name, | ||
ProviderName: rv.Provider.Type, | ||
Values: cfg, | ||
}) | ||
} | ||
queries = append(queries, query.Resource{ | ||
Address: addr, | ||
Components: comps, | ||
}) | ||
} | ||
} | ||
|
||
// Recursively extract resources from all child module calls. | ||
for mk, mv := range mod.ModuleCalls { | ||
p := joinPath(modPath, mv.SourceAddr) | ||
|
||
// Try to load a module from a config directory. Only local modules are supported, other types of modules | ||
// will be skipped. | ||
child, diags := parser.LoadConfigDir(p) | ||
if diags.HasErrors() { | ||
// Skip unsupported modules | ||
continue | ||
} | ||
|
||
body, ok := mv.Config.(*hclsyntax.Body) | ||
if !ok { | ||
return nil, fmt.Errorf("invalid module call body") | ||
} | ||
|
||
// Extract variables from the module call block to pass down to the module. It's a map of | ||
// variable names to their evaluated values. | ||
vars := make(map[string]cty.Value) | ||
for _, attr := range body.Attributes { | ||
val, diags := attr.Expr.Value(evalCtx) | ||
if diags != nil && diags.HasErrors() { | ||
continue | ||
} | ||
vars[attr.Name] = val | ||
} | ||
|
||
nextEvalCtx := getEvalCtx(child, vars) | ||
|
||
// If the module call contains a `providers` block, it should replace the implicit provider | ||
// inheritance. Instead, a new map of parent to child providers is created. | ||
// https://www.terraform.io/docs/language/modules/develop/providers.html#passing-providers-explicitly | ||
var childProvs map[string]Provider | ||
if len(mv.Providers) == 0 { | ||
childProvs = providers | ||
} else { | ||
childProvs = make(map[string]Provider) | ||
for _, p := range mv.Providers { | ||
prov, ok := providers[p.InParent.String()] | ||
if !ok { | ||
continue | ||
} | ||
childProvs[p.InChild.String()] = prov | ||
} | ||
} | ||
|
||
// Set the full path of the child module. A child module must start with the path to the parent module, | ||
// then the word "module", then the module name, all separated by dots. | ||
var nextModPath string | ||
if modName != "" { | ||
nextModPath = fmt.Sprintf("%s.module.%s", modName, mk) | ||
} else { | ||
nextModPath = fmt.Sprintf("module.%s", mk) | ||
} | ||
|
||
qs, err := extractHCLModule(childProvs, parser, p, nextModPath, child, nextEvalCtx) | ||
if err != nil { | ||
return nil, err | ||
} | ||
queries = append(queries, qs...) | ||
} | ||
|
||
return queries, nil | ||
} | ||
|
||
// getEvalCtx returns the evaluation context of the given module with variable values set. | ||
func getEvalCtx(mod *configs.Module, vars map[string]cty.Value) *hcl.EvalContext { | ||
// Set default values for undefined variables. | ||
if vars == nil { | ||
vars = make(map[string]cty.Value) | ||
} | ||
for vk, vv := range mod.Variables { | ||
if _, ok := vars[vk]; !ok { | ||
vars[vk] = vv.Default | ||
} | ||
} | ||
|
||
// Initialize the evaluation context that will be used by the Terraform parser to fill in values | ||
// of variables. E.g. the `var` element contains variable values accessible from HCL using `var.*`. | ||
evalCtx := &hcl.EvalContext{ | ||
Variables: map[string]cty.Value{ | ||
"var": cty.ObjectVal(vars), | ||
}, | ||
} | ||
|
||
// Set values of locals. | ||
lm := make(map[string]cty.Value) | ||
for lk, lv := range mod.Locals { | ||
val, diags := lv.Expr.Value(evalCtx) | ||
if diags != nil && diags.HasErrors() { | ||
continue | ||
} | ||
lm[lk] = val | ||
} | ||
evalCtx.Variables["local"] = cty.ObjectVal(lm) | ||
|
||
return evalCtx | ||
} | ||
|
||
// getBodyJSON gets all the variables in a JSON format of the actual representation | ||
func getBodyJSON(b *hclsyntax.Body, evalCtx *hcl.EvalContext) map[string]interface{} { | ||
links := make(map[string]interface{}) | ||
// Each attribute of the body is casted to the correct type and placed into the links map. | ||
for attrk, attrv := range b.Attributes { | ||
val, _ := attrv.Expr.Value(evalCtx) | ||
if !val.IsKnown() { | ||
continue | ||
} | ||
switch val.Type() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No need to deal with maps or slices? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMO there's no need, I can't think of any resource that would use them for any cost-related attributes. For sure we don't support any ATM. Though there's |
||
case cty.String: | ||
links[attrk] = val.AsString() | ||
case cty.Number: | ||
f, _ := val.AsBigFloat().Float64() | ||
links[attrk] = f | ||
case cty.Bool: | ||
links[attrk] = val.True() | ||
} | ||
} | ||
for _, block := range b.Blocks { | ||
cfg := getBodyJSON(block.Body, evalCtx) | ||
// We continue to not add empty information to the config | ||
// so it's clean and only has required information | ||
if len(cfg) == 0 { | ||
continue | ||
} | ||
if _, ok := links[block.Type]; !ok { | ||
links[block.Type] = make([]interface{}, 0) | ||
} | ||
links[block.Type] = append(links[block.Type].([]interface{}), cfg) | ||
} | ||
|
||
return links | ||
} | ||
|
||
// getHCLProviders extracts provider configurations from the module and initializes the providers using the | ||
// providerInitializers slice. The resulting map of aliases to instantiated providers is then returned. | ||
func getHCLProviders(mod *configs.Module, evalCtx *hcl.EvalContext, providerInitializers []ProviderInitializer) (map[string]Provider, error) { | ||
piMap := make(map[string]ProviderInitializer) | ||
for _, pi := range providerInitializers { | ||
for _, name := range pi.MatchNames { | ||
piMap[name] = pi | ||
} | ||
} | ||
|
||
providers := make(map[string]Provider) | ||
for pk, pv := range mod.ProviderConfigs { | ||
pi, ok := piMap[pv.Name] | ||
if !ok { | ||
continue | ||
} | ||
|
||
body, ok := pv.Config.(*hclsyntax.Body) | ||
if !ok { | ||
return nil, fmt.Errorf("bad body") | ||
} | ||
|
||
cfg := getBodyJSON(body, evalCtx) | ||
values := make(map[string]string) | ||
for k, v := range cfg { | ||
if s, ok := v.(string); ok { | ||
values[k] = s | ||
} | ||
} | ||
|
||
prov, err := pi.Provider(values) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to initialize provider: %w", err) | ||
} | ||
providers[pk] = prov | ||
} | ||
|
||
return providers, nil | ||
} | ||
|
||
// joinPath joins two directory paths together, unless target is absolute, in which case it is returned instead. | ||
func joinPath(source, target string) string { | ||
if path.IsAbs(target) { | ||
return target | ||
} | ||
return path.Join(source, target) | ||
} |
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.
Can only be found I guess?
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.
Since it's an
interface{}
, it will benil
if not found in the map. This is then checked later down: https://github.com/cycloidio/terracost/pull/45/files/5583ecf7da89196e5d59e1c64f1d5838d13148e3..4e0d5949fce9e0218d1f2b9f0f9184dc967e4952#diff-d88d3b92409f1211e4b636160025511bac9b8ab47d7c06375fd0092f53cf5763R69