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