Files
terraform-docs/internal/terraform/module.go
Khosrow Moossavi 3cc4f4416f Fix issues reported by golangci-lint
Signed-off-by: Khosrow Moossavi <khos2ow@gmail.com>
2021-03-16 17:33:10 -04:00

571 lines
17 KiB
Go

/*
Copyright 2021 The terraform-docs Authors.
Licensed under the MIT license (the "License"); you may not
use this file except in compliance with the License.
You may obtain a copy of the License at the LICENSE file in
the root directory of this source tree.
*/
package terraform
import (
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
terraformsdk "github.com/terraform-docs/plugin-sdk/terraform"
"github.com/terraform-docs/terraform-config-inspect/tfconfig"
"github.com/terraform-docs/terraform-docs/internal/reader"
"github.com/terraform-docs/terraform-docs/internal/types"
)
// Module represents a Terraform module. It consists of
//
// - Header ('header' json key): Module header found in shape of multi line '*.tf' comments or an entire file
// - Footer ('footer' json key): Module footer found in shape of multi line '*.tf' comments or an entire file
// - Inputs ('inputs' json key): List of input 'variables' extracted from the Terraform module .tf files
// - ModuleCalls ('modules' json key): List of 'modules' extracted from the Terraform module .tf files
// - Outputs ('outputs' json key): List of 'outputs' extracted from Terraform module .tf files
// - Providers ('providers' json key): List of 'providers' extracted from resources used in Terraform module
// - Requirements ('requirements' json key): List of 'requirements' extracted from the Terraform module .tf files
// - Resources ('resources' json key): List of 'resources' extracted from the Terraform module .tf files
type Module struct {
XMLName xml.Name `json:"-" toml:"-" xml:"module" yaml:"-"`
Header string `json:"header" toml:"header" xml:"header" yaml:"header"`
Footer string `json:"footer" toml:"footer" xml:"footer" yaml:"footer"`
Inputs []*Input `json:"inputs" toml:"inputs" xml:"inputs>input" yaml:"inputs"`
ModuleCalls []*ModuleCall `json:"modules" toml:"modules" xml:"modules>module" yaml:"modules"`
Outputs []*Output `json:"outputs" toml:"outputs" xml:"outputs>output" yaml:"outputs"`
Providers []*Provider `json:"providers" toml:"providers" xml:"providers>provider" yaml:"providers"`
Requirements []*Requirement `json:"requirements" toml:"requirements" xml:"requirements>requirement" yaml:"requirements"`
Resources []*Resource `json:"resources" toml:"resources" xml:"resources>resource" yaml:"resources"`
RequiredInputs []*Input `json:"-" toml:"-" xml:"-" yaml:"-"`
OptionalInputs []*Input `json:"-" toml:"-" xml:"-" yaml:"-"`
}
// HasHeader indicates if the module has header.
func (m *Module) HasHeader() bool {
return len(m.Header) > 0
}
// HasFooter indicates if the module has footer.
func (m *Module) HasFooter() bool {
return len(m.Footer) > 0
}
// HasInputs indicates if the module has inputs.
func (m *Module) HasInputs() bool {
return len(m.Inputs) > 0
}
// HasModuleCalls indicates if the module has modulecalls.
func (m *Module) HasModuleCalls() bool {
return len(m.ModuleCalls) > 0
}
// HasOutputs indicates if the module has outputs.
func (m *Module) HasOutputs() bool {
return len(m.Outputs) > 0
}
// HasProviders indicates if the module has providers.
func (m *Module) HasProviders() bool {
return len(m.Providers) > 0
}
// HasRequirements indicates if the module has requirements.
func (m *Module) HasRequirements() bool {
return len(m.Requirements) > 0
}
// HasResources indicates if the module has resources.
func (m *Module) HasResources() bool {
return len(m.Resources) > 0
}
// Convert internal Module to its equivalent in plugin-sdk
func (m *Module) Convert() terraformsdk.Module {
return terraformsdk.NewModule(
terraformsdk.WithHeader(m.Header),
terraformsdk.WithFooter(m.Footer),
terraformsdk.WithInputs(inputs(m.Inputs).convert()),
terraformsdk.WithModuleCalls(modulecalls(m.ModuleCalls).convert()),
terraformsdk.WithOutputs(outputs(m.Outputs).convert()),
terraformsdk.WithProviders(providers(m.Providers).convert()),
terraformsdk.WithRequirements(requirements(m.Requirements).convert()),
terraformsdk.WithResources(resources(m.Resources).convert()),
terraformsdk.WithRequiredInputs(inputs(m.RequiredInputs).convert()),
terraformsdk.WithOptionalInputs(inputs(m.OptionalInputs).convert()),
)
}
// LoadWithOptions returns new instance of Module with all the inputs and
// outputs discovered from provided 'path' containing Terraform config
func LoadWithOptions(options *Options) (*Module, error) {
tfmodule, err := loadModule(options.Path)
if err != nil {
return nil, err
}
module, err := loadModuleItems(tfmodule, options)
if err != nil {
return nil, err
}
sortItems(module, options.SortBy)
return module, nil
}
func loadModule(path string) (*tfconfig.Module, error) {
module, diag := tfconfig.LoadModule(path)
if diag != nil && diag.HasErrors() {
return nil, diag
}
return module, nil
}
func loadModuleItems(tfmodule *tfconfig.Module, options *Options) (*Module, error) {
header, err := loadHeader(options)
if err != nil {
return nil, err
}
footer, err := loadFooter(options)
if err != nil {
return nil, err
}
inputs, required, optional := loadInputs(tfmodule)
modulecalls := loadModulecalls(tfmodule)
outputs, err := loadOutputs(tfmodule, options)
if err != nil {
return nil, err
}
providers := loadProviders(tfmodule)
requirements := loadRequirements(tfmodule)
resources := loadResources(tfmodule)
return &Module{
Header: header,
Footer: footer,
Inputs: inputs,
ModuleCalls: modulecalls,
Outputs: outputs,
Providers: providers,
Requirements: requirements,
Resources: resources,
RequiredInputs: required,
OptionalInputs: optional,
}, nil
}
func getFileFormat(filename string) string {
if filename == "" {
return ""
}
last := strings.LastIndex(filename, ".")
if last == -1 {
return ""
}
return filename[last:]
}
func isFileFormatSupported(filename string, section string) (bool, error) {
if section == "" {
return false, errors.New("section is missing")
}
if filename == "" {
return false, fmt.Errorf("--%s-from value is missing", section)
}
switch getFileFormat(filename) {
case ".adoc", ".md", ".tf", ".txt":
return true, nil
}
return false, fmt.Errorf("only .adoc, .md, .tf, and .txt formats are supported to read %s from", section)
}
func loadHeader(options *Options) (string, error) {
if !options.ShowHeader {
return "", nil
}
return loadSection(options, options.HeaderFromFile, "header")
}
func loadFooter(options *Options) (string, error) {
if !options.ShowFooter {
return "", nil
}
return loadSection(options, options.FooterFromFile, "footer")
}
func loadSection(options *Options, file string, section string) (string, error) { //nolint:gocyclo
// NOTE(khos2ow): this function is over our cyclomatic complexity goal.
// Be wary when adding branches, and look for functionality that could
// be reasonably moved into an injected dependency.
if section == "" {
return "", errors.New("section is missing")
}
filename := filepath.Join(options.Path, file)
if ok, err := isFileFormatSupported(file, section); !ok {
return "", err
}
if info, err := os.Stat(filename); os.IsNotExist(err) || info.IsDir() {
if section == "header" && file == "main.tf" {
return "", nil // absorb the error to not break workflow for default value of header and missing 'main.tf'
}
return "", err // user explicitly asked for a file which doesn't exist
}
if getFileFormat(file) != ".tf" {
content, err := ioutil.ReadFile(filepath.Clean(filename))
if err != nil {
return "", err
}
return string(content), nil
}
lines := reader.Lines{
FileName: filename,
LineNum: -1,
Condition: func(line string) bool {
line = strings.TrimSpace(line)
return strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") || strings.HasPrefix(line, "*/")
},
Parser: func(line string) (string, bool) {
tmp := strings.TrimSpace(line)
if strings.HasPrefix(tmp, "/*") || strings.HasPrefix(tmp, "*/") {
return "", false
}
if tmp == "*" {
return "", true
}
line = strings.TrimLeft(line, " ")
line = strings.TrimRight(line, "\r\n")
line = strings.TrimPrefix(line, "* ")
return line, true
},
}
sectionText, err := lines.Extract()
if err != nil {
return "", err
}
return strings.Join(sectionText, "\n"), nil
}
func loadInputs(tfmodule *tfconfig.Module) ([]*Input, []*Input, []*Input) {
var inputs = make([]*Input, 0, len(tfmodule.Variables))
var required = make([]*Input, 0, len(tfmodule.Variables))
var optional = make([]*Input, 0, len(tfmodule.Variables))
for _, input := range tfmodule.Variables {
// convert CRLF to LF early on (https://github.com/terraform-docs/terraform-docs/issues/305)
inputDescription := strings.ReplaceAll(input.Description, "\r\n", "\n")
if inputDescription == "" {
inputDescription = loadComments(input.Pos.Filename, input.Pos.Line)
}
i := &Input{
Name: input.Name,
Type: types.TypeOf(input.Type, input.Default),
Description: types.String(inputDescription),
Default: types.ValueOf(input.Default),
Required: input.Required,
Position: Position{
Filename: input.Pos.Filename,
Line: input.Pos.Line,
},
}
inputs = append(inputs, i)
if i.HasDefault() {
optional = append(optional, i)
} else {
required = append(required, i)
}
}
return inputs, required, optional
}
func loadModulecalls(tfmodule *tfconfig.Module) []*ModuleCall {
var modules = make([]*ModuleCall, 0)
for _, m := range tfmodule.ModuleCalls {
modules = append(modules, &ModuleCall{
Name: m.Name,
Source: m.Source,
Version: m.Version,
Position: Position{
Filename: m.Pos.Filename,
Line: m.Pos.Line,
},
})
}
return modules
}
func loadOutputs(tfmodule *tfconfig.Module, options *Options) ([]*Output, error) {
outputs := make([]*Output, 0, len(tfmodule.Outputs))
values := make(map[string]*output)
if options.OutputValues {
var err error
values, err = loadOutputValues(options)
if err != nil {
return nil, err
}
}
for _, o := range tfmodule.Outputs {
description := o.Description
if description == "" {
description = loadComments(o.Pos.Filename, o.Pos.Line)
}
output := &Output{
Name: o.Name,
Description: types.String(description),
Position: Position{
Filename: o.Pos.Filename,
Line: o.Pos.Line,
},
ShowValue: options.OutputValues,
}
if options.OutputValues {
output.Sensitive = values[output.Name].Sensitive
if values[output.Name].Sensitive {
output.Value = types.ValueOf(`<sensitive>`)
} else {
output.Value = types.ValueOf(values[output.Name].Value)
}
}
outputs = append(outputs, output)
}
return outputs, nil
}
func loadOutputValues(options *Options) (map[string]*output, error) {
var out []byte
var err error
if options.OutputValuesPath == "" {
cmd := exec.Command("terraform", "output", "-json")
cmd.Dir = options.Path
if out, err = cmd.Output(); err != nil {
return nil, fmt.Errorf("caught error while reading the terraform outputs: %w", err)
}
} else if out, err = ioutil.ReadFile(options.OutputValuesPath); err != nil {
return nil, fmt.Errorf("caught error while reading the terraform outputs file at %s: %w", options.OutputValuesPath, err)
}
var terraformOutputs map[string]*output
err = json.Unmarshal(out, &terraformOutputs)
if err != nil {
return nil, err
}
return terraformOutputs, err
}
func loadProviders(tfmodule *tfconfig.Module) []*Provider {
resources := []map[string]*tfconfig.Resource{tfmodule.ManagedResources, tfmodule.DataResources}
discovered := make(map[string]*Provider)
for _, resource := range resources {
for _, r := range resource {
var version = ""
if rv, ok := tfmodule.RequiredProviders[r.Provider.Name]; ok && len(rv.VersionConstraints) > 0 {
version = strings.Join(rv.VersionConstraints, " ")
}
key := fmt.Sprintf("%s.%s", r.Provider.Name, r.Provider.Alias)
discovered[key] = &Provider{
Name: r.Provider.Name,
Alias: types.String(r.Provider.Alias),
Version: types.String(version),
Position: Position{
Filename: r.Pos.Filename,
Line: r.Pos.Line,
},
}
}
}
providers := make([]*Provider, 0, len(discovered))
for _, provider := range discovered {
providers = append(providers, provider)
}
return providers
}
func loadRequirements(tfmodule *tfconfig.Module) []*Requirement {
var requirements = make([]*Requirement, 0)
for _, core := range tfmodule.RequiredCore {
requirements = append(requirements, &Requirement{
Name: "terraform",
Version: types.String(core),
})
}
names := make([]string, 0, len(tfmodule.RequiredProviders))
for n := range tfmodule.RequiredProviders {
names = append(names, n)
}
sort.Strings(names)
for _, name := range names {
for _, version := range tfmodule.RequiredProviders[name].VersionConstraints {
requirements = append(requirements, &Requirement{
Name: name,
Version: types.String(version),
})
}
}
return requirements
}
func loadResources(tfmodule *tfconfig.Module) []*Resource {
allResources := []map[string]*tfconfig.Resource{tfmodule.ManagedResources, tfmodule.DataResources}
discovered := make(map[string]*Resource)
for _, resource := range allResources {
for _, r := range resource {
var version string
if rv, ok := tfmodule.RequiredProviders[r.Provider.Name]; ok {
version = resourceVersion(rv.VersionConstraints)
}
var source string
if len(tfmodule.RequiredProviders[r.Provider.Name].Source) > 0 {
source = tfmodule.RequiredProviders[r.Provider.Name].Source
} else {
source = fmt.Sprintf("%s/%s", "hashicorp", r.Provider.Name)
}
rType := strings.TrimPrefix(r.Type, r.Provider.Name+"_")
key := fmt.Sprintf("%s.%s.%s.%s", r.Provider.Name, r.Mode, rType, r.Name)
discovered[key] = &Resource{
Type: rType,
Name: r.Name,
Mode: r.Mode.String(),
ProviderName: r.Provider.Name,
ProviderSource: source,
Version: types.String(version),
Position: Position{
Filename: r.Pos.Filename,
Line: r.Pos.Line,
},
}
}
}
resources := make([]*Resource, 0, len(discovered))
for _, resource := range discovered {
resources = append(resources, resource)
}
return resources
}
func resourceVersion(constraints []string) string {
if len(constraints) == 0 {
return "latest"
}
versionParts := strings.Split(constraints[len(constraints)-1], " ")
switch len(versionParts) {
case 1:
if _, err := strconv.Atoi(versionParts[0][0:1]); err != nil {
if versionParts[0][0:1] == "=" {
return versionParts[0][1:]
}
return "latest"
}
return versionParts[0]
case 2:
if versionParts[0] == "=" {
return versionParts[1]
}
}
return "latest"
}
func loadComments(filename string, lineNum int) string {
lines := reader.Lines{
FileName: filename,
LineNum: lineNum,
Condition: func(line string) bool {
return strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//")
},
Parser: func(line string) (string, bool) {
line = strings.TrimSpace(line)
line = strings.TrimPrefix(line, "#")
line = strings.TrimPrefix(line, "//")
line = strings.TrimSpace(line)
return line, true
},
}
comment, err := lines.Extract()
if err != nil {
return "" // absorb the error, we don't need to bubble it up or break the execution
}
return strings.Join(comment, " ")
}
func sortItems(tfmodule *Module, sortby *SortBy) { //nolint:gocyclo
// NOTE(khos2ow): this function is over our cyclomatic complexity goal.
// Be wary when adding branches, and look for functionality that could
// be reasonably moved into an injected dependency.
// inputs
switch {
case sortby.Type:
sort.Sort(inputsSortedByType(tfmodule.Inputs))
sort.Sort(inputsSortedByType(tfmodule.RequiredInputs))
sort.Sort(inputsSortedByType(tfmodule.OptionalInputs))
case sortby.Required:
sort.Sort(inputsSortedByRequired(tfmodule.Inputs))
sort.Sort(inputsSortedByRequired(tfmodule.RequiredInputs))
sort.Sort(inputsSortedByRequired(tfmodule.OptionalInputs))
case sortby.Name:
sort.Sort(inputsSortedByName(tfmodule.Inputs))
sort.Sort(inputsSortedByName(tfmodule.RequiredInputs))
sort.Sort(inputsSortedByName(tfmodule.OptionalInputs))
default:
sort.Sort(inputsSortedByPosition(tfmodule.Inputs))
sort.Sort(inputsSortedByPosition(tfmodule.RequiredInputs))
sort.Sort(inputsSortedByPosition(tfmodule.OptionalInputs))
}
// outputs
if sortby.Name || sortby.Required || sortby.Type {
sort.Sort(outputsSortedByName(tfmodule.Outputs))
} else {
sort.Sort(outputsSortedByPosition(tfmodule.Outputs))
}
// providers
if sortby.Name || sortby.Required || sortby.Type {
sort.Sort(providersSortedByName(tfmodule.Providers))
} else {
sort.Sort(providersSortedByPosition(tfmodule.Providers))
}
// resources (always sorted)
sort.Sort(resourcesSortedByType(tfmodule.Resources))
// modules
switch {
case sortby.Name || sortby.Required:
sort.Sort(modulecallsSortedByName(tfmodule.ModuleCalls))
case sortby.Type:
sort.Sort(modulecallsSortedBySource(tfmodule.ModuleCalls))
default:
sort.Sort(modulecallsSortedByPosition(tfmodule.ModuleCalls))
}
}