mirror of
https://github.com/terraform-docs/terraform-docs.git
synced 2026-03-27 04:48:33 +07:00
573 lines
15 KiB
Go
573 lines
15 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"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/hashicorp/hcl/v2/hclsimple"
|
|
|
|
"github.com/terraform-docs/terraform-config-inspect/tfconfig"
|
|
"github.com/terraform-docs/terraform-docs/internal/reader"
|
|
"github.com/terraform-docs/terraform-docs/internal/types"
|
|
"github.com/terraform-docs/terraform-docs/print"
|
|
)
|
|
|
|
// LoadWithOptions returns new instance of Module with all the inputs and
|
|
// outputs discovered from provided 'path' containing Terraform config
|
|
func LoadWithOptions(config *print.Config) (*Module, error) {
|
|
tfmodule, err := loadModule(config.ModuleRoot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
module, err := loadModuleItems(tfmodule, config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sortItems(module, config)
|
|
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, config *print.Config) (*Module, error) {
|
|
header, err := loadHeader(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
footer, err := loadFooter(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
inputs, required, optional := loadInputs(tfmodule, config)
|
|
modulecalls := loadModulecalls(tfmodule, config)
|
|
outputs, err := loadOutputs(tfmodule, config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
providers := loadProviders(tfmodule, config)
|
|
requirements := loadRequirements(tfmodule)
|
|
resources := loadResources(tfmodule, config)
|
|
|
|
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", ".tofu", ".txt":
|
|
return true, nil
|
|
}
|
|
return false, fmt.Errorf("only .adoc, .md, .tf, .tofu and .txt formats are supported to read %s from", section)
|
|
}
|
|
|
|
func loadHeader(config *print.Config) (string, error) {
|
|
if !config.Sections.Header {
|
|
return "", nil
|
|
}
|
|
return loadSection(config, config.HeaderFrom, "header")
|
|
}
|
|
|
|
func loadFooter(config *print.Config) (string, error) {
|
|
if !config.Sections.Footer {
|
|
return "", nil
|
|
}
|
|
return loadSection(config, config.FooterFrom, "footer")
|
|
}
|
|
|
|
func loadSection(config *print.Config, 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(config.ModuleRoot, 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
|
|
}
|
|
format := getFileFormat(file)
|
|
if format != ".tf" && format != ".tofu" {
|
|
content, err := os.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, config *print.Config) ([]*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 {
|
|
comments := loadComments(input.Pos.Filename, input.Pos.Line)
|
|
|
|
// skip over inputs that are marked as being ignored
|
|
if strings.Contains(comments, "terraform-docs-ignore") {
|
|
continue
|
|
}
|
|
|
|
// 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 == "" && config.Settings.ReadComments {
|
|
inputDescription = comments
|
|
}
|
|
|
|
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 formatSource(s, v string) (source, version string) {
|
|
substr := "?ref="
|
|
|
|
if v != "" {
|
|
return s, v
|
|
}
|
|
|
|
pos := strings.LastIndex(s, substr)
|
|
if pos == -1 {
|
|
return s, version
|
|
}
|
|
|
|
adjustedPos := pos + len(substr)
|
|
if adjustedPos >= len(s) {
|
|
return s, version
|
|
}
|
|
|
|
source = s[0:pos]
|
|
version = s[adjustedPos:]
|
|
|
|
return source, version
|
|
}
|
|
|
|
func loadModulecalls(tfmodule *tfconfig.Module, config *print.Config) []*ModuleCall {
|
|
var modules = make([]*ModuleCall, 0)
|
|
var source, version string
|
|
|
|
for _, m := range tfmodule.ModuleCalls {
|
|
comments := loadComments(m.Pos.Filename, m.Pos.Line)
|
|
|
|
// skip over modules that are marked as being ignored
|
|
if strings.Contains(comments, "terraform-docs-ignore") {
|
|
continue
|
|
}
|
|
|
|
description := ""
|
|
if config.Settings.ReadComments {
|
|
description = comments
|
|
}
|
|
|
|
source, version = formatSource(m.Source, m.Version)
|
|
|
|
modules = append(modules, &ModuleCall{
|
|
Name: m.Name,
|
|
Source: source,
|
|
Version: version,
|
|
Description: types.String(description),
|
|
Position: Position{
|
|
Filename: m.Pos.Filename,
|
|
Line: m.Pos.Line,
|
|
},
|
|
})
|
|
}
|
|
return modules
|
|
}
|
|
|
|
func loadOutputs(tfmodule *tfconfig.Module, config *print.Config) ([]*Output, error) {
|
|
outputs := make([]*Output, 0, len(tfmodule.Outputs))
|
|
values := make(map[string]*output)
|
|
if config.OutputValues.Enabled {
|
|
var err error
|
|
values, err = loadOutputValues(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
for _, o := range tfmodule.Outputs {
|
|
comments := loadComments(o.Pos.Filename, o.Pos.Line)
|
|
|
|
// skip over outputs that are marked as being ignored
|
|
if strings.Contains(comments, "terraform-docs-ignore") {
|
|
continue
|
|
}
|
|
|
|
// convert CRLF to LF early on (https://github.com/terraform-docs/terraform-docs/issues/584)
|
|
description := strings.ReplaceAll(o.Description, "\r\n", "\n")
|
|
if description == "" && config.Settings.ReadComments {
|
|
description = comments
|
|
}
|
|
|
|
output := &Output{
|
|
Name: o.Name,
|
|
Description: types.String(description),
|
|
Position: Position{
|
|
Filename: o.Pos.Filename,
|
|
Line: o.Pos.Line,
|
|
},
|
|
ShowValue: config.OutputValues.Enabled,
|
|
}
|
|
|
|
if config.OutputValues.Enabled {
|
|
if value, ok := values[output.Name]; ok {
|
|
output.Sensitive = value.Sensitive
|
|
output.Value = types.ValueOf(value.Value)
|
|
} else {
|
|
output.Value = types.ValueOf("null")
|
|
}
|
|
|
|
if output.Sensitive {
|
|
output.Value = types.ValueOf(`<sensitive>`)
|
|
}
|
|
}
|
|
outputs = append(outputs, output)
|
|
}
|
|
return outputs, nil
|
|
}
|
|
|
|
func loadOutputValues(config *print.Config) (map[string]*output, error) {
|
|
var out []byte
|
|
var err error
|
|
if config.OutputValues.From == "" {
|
|
cmd := exec.Command("terraform", "output", "-json")
|
|
cmd.Dir = config.ModuleRoot
|
|
if out, err = cmd.Output(); err != nil {
|
|
return nil, fmt.Errorf("caught error while reading the terraform outputs: %w", err)
|
|
}
|
|
} else if out, err = os.ReadFile(config.OutputValues.From); err != nil {
|
|
return nil, fmt.Errorf("caught error while reading the terraform outputs file at %s: %w", config.OutputValues.From, 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, config *print.Config) []*Provider { //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.
|
|
|
|
type provider struct {
|
|
Name string `hcl:"name,label"`
|
|
Version string `hcl:"version"`
|
|
Constraints *string `hcl:"constraints"`
|
|
Hashes []string `hcl:"hashes"`
|
|
}
|
|
type lockfile struct {
|
|
Provider []provider `hcl:"provider,block"`
|
|
}
|
|
lock := make(map[string]provider)
|
|
|
|
if config.Settings.LockFile {
|
|
var lf lockfile
|
|
|
|
filename := filepath.Join(config.ModuleRoot, ".terraform.lock.hcl")
|
|
if err := hclsimple.DecodeFile(filename, nil, &lf); err == nil {
|
|
for i := range lf.Provider {
|
|
segments := strings.Split(lf.Provider[i].Name, "/")
|
|
name := segments[len(segments)-1]
|
|
lock[name] = lf.Provider[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
resources := []map[string]*tfconfig.Resource{tfmodule.ManagedResources, tfmodule.DataResources}
|
|
discovered := make(map[string]*Provider)
|
|
|
|
for _, resource := range resources {
|
|
for _, r := range resource {
|
|
comments := loadComments(r.Pos.Filename, r.Pos.Line)
|
|
|
|
// skip over resources that are marked as being ignored
|
|
if strings.Contains(comments, "terraform-docs-ignore") {
|
|
continue
|
|
}
|
|
|
|
var version = ""
|
|
if l, ok := lock[r.Provider.Name]; ok {
|
|
version = l.Version
|
|
} else 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)
|
|
if _, ok := discovered[key]; ok {
|
|
continue
|
|
}
|
|
|
|
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, config *print.Config) []*Resource {
|
|
allResources := []map[string]*tfconfig.Resource{tfmodule.ManagedResources, tfmodule.DataResources}
|
|
discovered := make(map[string]*Resource)
|
|
|
|
for _, resource := range allResources {
|
|
for _, r := range resource {
|
|
comments := loadComments(r.Pos.Filename, r.Pos.Line)
|
|
|
|
// skip over resources that are marked as being ignored
|
|
if strings.Contains(comments, "terraform-docs-ignore") {
|
|
continue
|
|
}
|
|
|
|
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)
|
|
|
|
description := ""
|
|
if config.Settings.ReadComments {
|
|
description = comments
|
|
}
|
|
|
|
discovered[key] = &Resource{
|
|
Type: rType,
|
|
Name: r.Name,
|
|
Mode: r.Mode.String(),
|
|
ProviderName: r.Provider.Name,
|
|
ProviderSource: source,
|
|
Version: types.String(version),
|
|
Description: types.String(description),
|
|
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, config *print.Config) {
|
|
// inputs
|
|
inputs(tfmodule.Inputs).sort(config.Sort.Enabled, config.Sort.By)
|
|
inputs(tfmodule.RequiredInputs).sort(config.Sort.Enabled, config.Sort.By)
|
|
inputs(tfmodule.OptionalInputs).sort(config.Sort.Enabled, config.Sort.By)
|
|
|
|
// outputs
|
|
outputs(tfmodule.Outputs).sort(config.Sort.Enabled, config.Sort.By)
|
|
|
|
// providers
|
|
providers(tfmodule.Providers).sort(config.Sort.Enabled, config.Sort.By)
|
|
|
|
// resources
|
|
resources(tfmodule.Resources).sort(config.Sort.Enabled, config.Sort.By)
|
|
|
|
// modules
|
|
modulecalls(tfmodule.ModuleCalls).sort(config.Sort.Enabled, config.Sort.By)
|
|
}
|