1/*
2Copyright 2021 The terraform-docs Authors.
3
4Licensed under the MIT license (the "License"); you may not
5use this file except in compliance with the License.
6
7You may obtain a copy of the License at the LICENSE file in
8the root directory of this source tree.
9*/
10
11package terraform
12
13import (
14	"encoding/json"
15	"errors"
16	"fmt"
17	"io/ioutil"
18	"os"
19	"os/exec"
20	"path/filepath"
21	"sort"
22	"strconv"
23	"strings"
24
25	"github.com/hashicorp/hcl/v2/hclsimple"
26
27	"github.com/terraform-docs/terraform-config-inspect/tfconfig"
28	"github.com/terraform-docs/terraform-docs/internal/reader"
29	"github.com/terraform-docs/terraform-docs/internal/types"
30	"github.com/terraform-docs/terraform-docs/print"
31)
32
33// LoadWithOptions returns new instance of Module with all the inputs and
34// outputs discovered from provided 'path' containing Terraform config
35func LoadWithOptions(config *print.Config) (*Module, error) {
36	tfmodule, err := loadModule(config.ModuleRoot)
37	if err != nil {
38		return nil, err
39	}
40
41	module, err := loadModuleItems(tfmodule, config)
42	if err != nil {
43		return nil, err
44	}
45	sortItems(module, config)
46	return module, nil
47}
48
49func loadModule(path string) (*tfconfig.Module, error) {
50	module, diag := tfconfig.LoadModule(path)
51	if diag != nil && diag.HasErrors() {
52		return nil, diag
53	}
54	return module, nil
55}
56
57func loadModuleItems(tfmodule *tfconfig.Module, config *print.Config) (*Module, error) {
58	header, err := loadHeader(config)
59	if err != nil {
60		return nil, err
61	}
62
63	footer, err := loadFooter(config)
64	if err != nil {
65		return nil, err
66	}
67
68	inputs, required, optional := loadInputs(tfmodule, config)
69	modulecalls := loadModulecalls(tfmodule, config)
70	outputs, err := loadOutputs(tfmodule, config)
71	if err != nil {
72		return nil, err
73	}
74	providers := loadProviders(tfmodule, config)
75	requirements := loadRequirements(tfmodule)
76	resources := loadResources(tfmodule, config)
77
78	return &Module{
79		Header:       header,
80		Footer:       footer,
81		Inputs:       inputs,
82		ModuleCalls:  modulecalls,
83		Outputs:      outputs,
84		Providers:    providers,
85		Requirements: requirements,
86		Resources:    resources,
87
88		RequiredInputs: required,
89		OptionalInputs: optional,
90	}, nil
91}
92
93func getFileFormat(filename string) string {
94	if filename == "" {
95		return ""
96	}
97	last := strings.LastIndex(filename, ".")
98	if last == -1 {
99		return ""
100	}
101	return filename[last:]
102}
103
104func isFileFormatSupported(filename string, section string) (bool, error) {
105	if section == "" {
106		return false, errors.New("section is missing")
107	}
108	if filename == "" {
109		return false, fmt.Errorf("--%s-from value is missing", section)
110	}
111	switch getFileFormat(filename) {
112	case ".adoc", ".md", ".tf", ".txt":
113		return true, nil
114	}
115	return false, fmt.Errorf("only .adoc, .md, .tf, and .txt formats are supported to read %s from", section)
116}
117
118func loadHeader(config *print.Config) (string, error) {
119	if !config.Sections.Header {
120		return "", nil
121	}
122	return loadSection(config, config.HeaderFrom, "header")
123}
124
125func loadFooter(config *print.Config) (string, error) {
126	if !config.Sections.Footer {
127		return "", nil
128	}
129	return loadSection(config, config.FooterFrom, "footer")
130}
131
132func loadSection(config *print.Config, file string, section string) (string, error) { //nolint:gocyclo
133	// NOTE(khos2ow): this function is over our cyclomatic complexity goal.
134	// Be wary when adding branches, and look for functionality that could
135	// be reasonably moved into an injected dependency.
136
137	if section == "" {
138		return "", errors.New("section is missing")
139	}
140	filename := filepath.Join(config.ModuleRoot, file)
141	if ok, err := isFileFormatSupported(file, section); !ok {
142		return "", err
143	}
144	if info, err := os.Stat(filename); os.IsNotExist(err) || info.IsDir() {
145		if section == "header" && file == "main.tf" {
146			return "", nil // absorb the error to not break workflow for default value of header and missing 'main.tf'
147		}
148		return "", err // user explicitly asked for a file which doesn't exist
149	}
150	if getFileFormat(file) != ".tf" {
151		content, err := ioutil.ReadFile(filepath.Clean(filename))
152		if err != nil {
153			return "", err
154		}
155		return string(content), nil
156	}
157	lines := reader.Lines{
158		FileName: filename,
159		LineNum:  -1,
160		Condition: func(line string) bool {
161			line = strings.TrimSpace(line)
162			return strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") || strings.HasPrefix(line, "*/")
163		},
164		Parser: func(line string) (string, bool) {
165			tmp := strings.TrimSpace(line)
166			if strings.HasPrefix(tmp, "/*") || strings.HasPrefix(tmp, "*/") {
167				return "", false
168			}
169			if tmp == "*" {
170				return "", true
171			}
172			line = strings.TrimLeft(line, " ")
173			line = strings.TrimRight(line, "\r\n")
174			line = strings.TrimPrefix(line, "* ")
175			return line, true
176		},
177	}
178	sectionText, err := lines.Extract()
179	if err != nil {
180		return "", err
181	}
182	return strings.Join(sectionText, "\n"), nil
183}
184
185func loadInputs(tfmodule *tfconfig.Module, config *print.Config) ([]*Input, []*Input, []*Input) {
186	var inputs = make([]*Input, 0, len(tfmodule.Variables))
187	var required = make([]*Input, 0, len(tfmodule.Variables))
188	var optional = make([]*Input, 0, len(tfmodule.Variables))
189
190	for _, input := range tfmodule.Variables {
191		// convert CRLF to LF early on (https://github.com/terraform-docs/terraform-docs/issues/305)
192		inputDescription := strings.ReplaceAll(input.Description, "\r\n", "\n")
193		if inputDescription == "" && config.Settings.ReadComments {
194			inputDescription = loadComments(input.Pos.Filename, input.Pos.Line)
195		}
196
197		i := &Input{
198			Name:        input.Name,
199			Type:        types.TypeOf(input.Type, input.Default),
200			Description: types.String(inputDescription),
201			Default:     types.ValueOf(input.Default),
202			Required:    input.Required,
203			Position: Position{
204				Filename: input.Pos.Filename,
205				Line:     input.Pos.Line,
206			},
207		}
208
209		inputs = append(inputs, i)
210
211		if i.HasDefault() {
212			optional = append(optional, i)
213		} else {
214			required = append(required, i)
215		}
216	}
217
218	return inputs, required, optional
219}
220
221func formatSource(s, v string) (source, version string) {
222	substr := "?ref="
223
224	if v != "" {
225		return s, v
226	}
227
228	pos := strings.LastIndex(s, substr)
229	if pos == -1 {
230		return s, version
231	}
232
233	adjustedPos := pos + len(substr)
234	if adjustedPos >= len(s) {
235		return s, version
236	}
237
238	source = s[0:pos]
239	version = s[adjustedPos:]
240
241	return source, version
242}
243
244func loadModulecalls(tfmodule *tfconfig.Module, config *print.Config) []*ModuleCall {
245	var modules = make([]*ModuleCall, 0)
246	var source, version string
247
248	for _, m := range tfmodule.ModuleCalls {
249		source, version = formatSource(m.Source, m.Version)
250
251		description := ""
252		if config.Settings.ReadComments {
253			description = loadComments(m.Pos.Filename, m.Pos.Line)
254		}
255
256		modules = append(modules, &ModuleCall{
257			Name:        m.Name,
258			Source:      source,
259			Version:     version,
260			Description: types.String(description),
261			Position: Position{
262				Filename: m.Pos.Filename,
263				Line:     m.Pos.Line,
264			},
265		})
266	}
267	return modules
268}
269
270func loadOutputs(tfmodule *tfconfig.Module, config *print.Config) ([]*Output, error) {
271	outputs := make([]*Output, 0, len(tfmodule.Outputs))
272	values := make(map[string]*output)
273	if config.OutputValues.Enabled {
274		var err error
275		values, err = loadOutputValues(config)
276		if err != nil {
277			return nil, err
278		}
279	}
280	for _, o := range tfmodule.Outputs {
281		description := o.Description
282		if description == "" && config.Settings.ReadComments {
283			description = loadComments(o.Pos.Filename, o.Pos.Line)
284		}
285
286		output := &Output{
287			Name:        o.Name,
288			Description: types.String(description),
289			Position: Position{
290				Filename: o.Pos.Filename,
291				Line:     o.Pos.Line,
292			},
293			ShowValue: config.OutputValues.Enabled,
294		}
295
296		if config.OutputValues.Enabled {
297			output.Sensitive = values[output.Name].Sensitive
298			if values[output.Name].Sensitive {
299				output.Value = types.ValueOf(`<sensitive>`)
300			} else {
301				output.Value = types.ValueOf(values[output.Name].Value)
302			}
303		}
304		outputs = append(outputs, output)
305	}
306	return outputs, nil
307}
308
309func loadOutputValues(config *print.Config) (map[string]*output, error) {
310	var out []byte
311	var err error
312	if config.OutputValues.From == "" {
313		cmd := exec.Command("terraform", "output", "-json")
314		cmd.Dir = config.ModuleRoot
315		if out, err = cmd.Output(); err != nil {
316			return nil, fmt.Errorf("caught error while reading the terraform outputs: %w", err)
317		}
318	} else if out, err = ioutil.ReadFile(config.OutputValues.From); err != nil {
319		return nil, fmt.Errorf("caught error while reading the terraform outputs file at %s: %w", config.OutputValues.From, err)
320	}
321	var terraformOutputs map[string]*output
322	err = json.Unmarshal(out, &terraformOutputs)
323	if err != nil {
324		return nil, err
325	}
326	return terraformOutputs, err
327}
328
329func loadProviders(tfmodule *tfconfig.Module, config *print.Config) []*Provider {
330	type provider struct {
331		Name        string   `hcl:"name,label"`
332		Version     string   `hcl:"version"`
333		Constraints *string  `hcl:"constraints"`
334		Hashes      []string `hcl:"hashes"`
335	}
336	type lockfile struct {
337		Provider []provider `hcl:"provider,block"`
338	}
339	lock := make(map[string]provider)
340
341	if config.Settings.LockFile {
342		var lf lockfile
343
344		filename := filepath.Join(config.ModuleRoot, ".terraform.lock.hcl")
345		if err := hclsimple.DecodeFile(filename, nil, &lf); err == nil {
346			for i := range lf.Provider {
347				segments := strings.Split(lf.Provider[i].Name, "/")
348				name := segments[len(segments)-1]
349				lock[name] = lf.Provider[i]
350			}
351		}
352	}
353
354	resources := []map[string]*tfconfig.Resource{tfmodule.ManagedResources, tfmodule.DataResources}
355	discovered := make(map[string]*Provider)
356
357	for _, resource := range resources {
358		for _, r := range resource {
359			var version = ""
360			if l, ok := lock[r.Provider.Name]; ok {
361				version = l.Version
362			} else if rv, ok := tfmodule.RequiredProviders[r.Provider.Name]; ok && len(rv.VersionConstraints) > 0 {
363				version = strings.Join(rv.VersionConstraints, " ")
364			}
365
366			key := fmt.Sprintf("%s.%s", r.Provider.Name, r.Provider.Alias)
367			discovered[key] = &Provider{
368				Name:    r.Provider.Name,
369				Alias:   types.String(r.Provider.Alias),
370				Version: types.String(version),
371				Position: Position{
372					Filename: r.Pos.Filename,
373					Line:     r.Pos.Line,
374				},
375			}
376		}
377	}
378
379	providers := make([]*Provider, 0, len(discovered))
380	for _, provider := range discovered {
381		providers = append(providers, provider)
382	}
383	return providers
384}
385
386func loadRequirements(tfmodule *tfconfig.Module) []*Requirement {
387	var requirements = make([]*Requirement, 0)
388	for _, core := range tfmodule.RequiredCore {
389		requirements = append(requirements, &Requirement{
390			Name:    "terraform",
391			Version: types.String(core),
392		})
393	}
394
395	names := make([]string, 0, len(tfmodule.RequiredProviders))
396	for n := range tfmodule.RequiredProviders {
397		names = append(names, n)
398	}
399
400	sort.Strings(names)
401
402	for _, name := range names {
403		for _, version := range tfmodule.RequiredProviders[name].VersionConstraints {
404			requirements = append(requirements, &Requirement{
405				Name:    name,
406				Version: types.String(version),
407			})
408		}
409	}
410	return requirements
411}
412
413func loadResources(tfmodule *tfconfig.Module, config *print.Config) []*Resource {
414	allResources := []map[string]*tfconfig.Resource{tfmodule.ManagedResources, tfmodule.DataResources}
415	discovered := make(map[string]*Resource)
416
417	for _, resource := range allResources {
418		for _, r := range resource {
419			var version string
420			if rv, ok := tfmodule.RequiredProviders[r.Provider.Name]; ok {
421				version = resourceVersion(rv.VersionConstraints)
422			}
423
424			var source string
425			if len(tfmodule.RequiredProviders[r.Provider.Name].Source) > 0 {
426				source = tfmodule.RequiredProviders[r.Provider.Name].Source
427			} else {
428				source = fmt.Sprintf("%s/%s", "hashicorp", r.Provider.Name)
429			}
430
431			rType := strings.TrimPrefix(r.Type, r.Provider.Name+"_")
432			key := fmt.Sprintf("%s.%s.%s.%s", r.Provider.Name, r.Mode, rType, r.Name)
433
434			description := ""
435			if config.Settings.ReadComments {
436				description = loadComments(r.Pos.Filename, r.Pos.Line)
437			}
438
439			discovered[key] = &Resource{
440				Type:           rType,
441				Name:           r.Name,
442				Mode:           r.Mode.String(),
443				ProviderName:   r.Provider.Name,
444				ProviderSource: source,
445				Version:        types.String(version),
446				Description:    types.String(description),
447				Position: Position{
448					Filename: r.Pos.Filename,
449					Line:     r.Pos.Line,
450				},
451			}
452		}
453	}
454
455	resources := make([]*Resource, 0, len(discovered))
456	for _, resource := range discovered {
457		resources = append(resources, resource)
458	}
459	return resources
460}
461
462func resourceVersion(constraints []string) string {
463	if len(constraints) == 0 {
464		return "latest"
465	}
466	versionParts := strings.Split(constraints[len(constraints)-1], " ")
467	switch len(versionParts) {
468	case 1:
469		if _, err := strconv.Atoi(versionParts[0][0:1]); err != nil {
470			if versionParts[0][0:1] == "=" {
471				return versionParts[0][1:]
472			}
473			return "latest"
474		}
475		return versionParts[0]
476	case 2:
477		if versionParts[0] == "=" {
478			return versionParts[1]
479		}
480	}
481	return "latest"
482}
483
484func loadComments(filename string, lineNum int) string {
485	lines := reader.Lines{
486		FileName: filename,
487		LineNum:  lineNum,
488		Condition: func(line string) bool {
489			return strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//")
490		},
491		Parser: func(line string) (string, bool) {
492			line = strings.TrimSpace(line)
493			line = strings.TrimPrefix(line, "#")
494			line = strings.TrimPrefix(line, "//")
495			line = strings.TrimSpace(line)
496			return line, true
497		},
498	}
499	comment, err := lines.Extract()
500	if err != nil {
501		return "" // absorb the error, we don't need to bubble it up or break the execution
502	}
503	return strings.Join(comment, " ")
504}
505
506func sortItems(tfmodule *Module, config *print.Config) {
507	// inputs
508	inputs(tfmodule.Inputs).sort(config.Sort.Enabled, config.Sort.By)
509	inputs(tfmodule.RequiredInputs).sort(config.Sort.Enabled, config.Sort.By)
510	inputs(tfmodule.OptionalInputs).sort(config.Sort.Enabled, config.Sort.By)
511
512	// outputs
513	outputs(tfmodule.Outputs).sort(config.Sort.Enabled, config.Sort.By)
514
515	// providers
516	providers(tfmodule.Providers).sort(config.Sort.Enabled, config.Sort.By)
517
518	// resources
519	resources(tfmodule.Resources).sort(config.Sort.Enabled, config.Sort.By)
520
521	// modules
522	modulecalls(tfmodule.ModuleCalls).sort(config.Sort.Enabled, config.Sort.By)
523}
524