558 lines
14 KiB
Go
558 lines
14 KiB
Go
package command
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/hashicorp/hcl/v2"
|
|
"github.com/hashicorp/hcl/v2/hclsyntax"
|
|
"github.com/hashicorp/hcl/v2/hclwrite"
|
|
"github.com/mitchellh/cli"
|
|
|
|
"github.com/hashicorp/terraform/configs"
|
|
"github.com/hashicorp/terraform/tfdiags"
|
|
)
|
|
|
|
const (
|
|
stdinArg = "-"
|
|
)
|
|
|
|
// FmtCommand is a Command implementation that rewrites Terraform config
|
|
// files to a canonical format and style.
|
|
type FmtCommand struct {
|
|
Meta
|
|
list bool
|
|
write bool
|
|
diff bool
|
|
check bool
|
|
recursive bool
|
|
input io.Reader // STDIN if nil
|
|
}
|
|
|
|
func (c *FmtCommand) Run(args []string) int {
|
|
if c.input == nil {
|
|
c.input = os.Stdin
|
|
}
|
|
|
|
args = c.Meta.process(args)
|
|
cmdFlags := c.Meta.defaultFlagSet("fmt")
|
|
cmdFlags.BoolVar(&c.list, "list", true, "list")
|
|
cmdFlags.BoolVar(&c.write, "write", true, "write")
|
|
cmdFlags.BoolVar(&c.diff, "diff", false, "diff")
|
|
cmdFlags.BoolVar(&c.check, "check", false, "check")
|
|
cmdFlags.BoolVar(&c.recursive, "recursive", false, "recursive")
|
|
cmdFlags.Usage = func() { c.Ui.Error(c.Help()) }
|
|
if err := cmdFlags.Parse(args); err != nil {
|
|
c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error()))
|
|
return 1
|
|
}
|
|
|
|
args = cmdFlags.Args()
|
|
if len(args) > 1 {
|
|
c.Ui.Error("The fmt command expects at most one argument.")
|
|
cmdFlags.Usage()
|
|
return 1
|
|
}
|
|
|
|
var paths []string
|
|
if len(args) == 0 {
|
|
paths = []string{"."}
|
|
} else if args[0] == stdinArg {
|
|
c.list = false
|
|
c.write = false
|
|
} else {
|
|
paths = []string{args[0]}
|
|
}
|
|
|
|
var output io.Writer
|
|
list := c.list // preserve the original value of -list
|
|
if c.check {
|
|
// set to true so we can use the list output to check
|
|
// if the input needs formatting
|
|
c.list = true
|
|
c.write = false
|
|
output = &bytes.Buffer{}
|
|
} else {
|
|
output = &cli.UiWriter{Ui: c.Ui}
|
|
}
|
|
|
|
diags := c.fmt(paths, c.input, output)
|
|
c.showDiagnostics(diags)
|
|
if diags.HasErrors() {
|
|
return 2
|
|
}
|
|
|
|
if c.check {
|
|
buf := output.(*bytes.Buffer)
|
|
ok := buf.Len() == 0
|
|
if list {
|
|
io.Copy(&cli.UiWriter{Ui: c.Ui}, buf)
|
|
}
|
|
if ok {
|
|
return 0
|
|
} else {
|
|
return 3
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
func (c *FmtCommand) fmt(paths []string, stdin io.Reader, stdout io.Writer) tfdiags.Diagnostics {
|
|
var diags tfdiags.Diagnostics
|
|
|
|
if len(paths) == 0 { // Assuming stdin, then.
|
|
if c.write {
|
|
diags = diags.Append(fmt.Errorf("Option -write cannot be used when reading from stdin"))
|
|
return diags
|
|
}
|
|
fileDiags := c.processFile("<stdin>", stdin, stdout, true)
|
|
diags = diags.Append(fileDiags)
|
|
return diags
|
|
}
|
|
|
|
for _, path := range paths {
|
|
path = c.normalizePath(path)
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
diags = diags.Append(fmt.Errorf("No file or directory at %s", path))
|
|
return diags
|
|
}
|
|
if info.IsDir() {
|
|
dirDiags := c.processDir(path, stdout)
|
|
diags = diags.Append(dirDiags)
|
|
} else {
|
|
switch filepath.Ext(path) {
|
|
case ".tf", ".tfvars":
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
// Open does not produce error messages that are end-user-appropriate,
|
|
// so we'll need to simplify here.
|
|
diags = diags.Append(fmt.Errorf("Failed to read file %s", path))
|
|
continue
|
|
}
|
|
|
|
fileDiags := c.processFile(c.normalizePath(path), f, stdout, false)
|
|
diags = diags.Append(fileDiags)
|
|
f.Close()
|
|
default:
|
|
diags = diags.Append(fmt.Errorf("Only .tf and .tfvars files can be processed with terraform fmt"))
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
return diags
|
|
}
|
|
|
|
func (c *FmtCommand) processFile(path string, r io.Reader, w io.Writer, isStdout bool) tfdiags.Diagnostics {
|
|
var diags tfdiags.Diagnostics
|
|
|
|
log.Printf("[TRACE] terraform fmt: Formatting %s", path)
|
|
|
|
src, err := ioutil.ReadAll(r)
|
|
if err != nil {
|
|
diags = diags.Append(fmt.Errorf("Failed to read %s", path))
|
|
return diags
|
|
}
|
|
|
|
// Register this path as a synthetic configuration source, so that any
|
|
// diagnostic errors can include the source code snippet
|
|
c.registerSynthConfigSource(path, src)
|
|
|
|
// File must be parseable as HCL native syntax before we'll try to format
|
|
// it. If not, the formatter is likely to make drastic changes that would
|
|
// be hard for the user to undo.
|
|
_, syntaxDiags := hclsyntax.ParseConfig(src, path, hcl.Pos{Line: 1, Column: 1})
|
|
if syntaxDiags.HasErrors() {
|
|
diags = diags.Append(syntaxDiags)
|
|
return diags
|
|
}
|
|
|
|
result := c.formatSourceCode(src, path)
|
|
|
|
if !bytes.Equal(src, result) {
|
|
// Something was changed
|
|
if c.list {
|
|
fmt.Fprintln(w, path)
|
|
}
|
|
if c.write {
|
|
err := ioutil.WriteFile(path, result, 0644)
|
|
if err != nil {
|
|
diags = diags.Append(fmt.Errorf("Failed to write %s", path))
|
|
return diags
|
|
}
|
|
}
|
|
if c.diff {
|
|
diff, err := bytesDiff(src, result, path)
|
|
if err != nil {
|
|
diags = diags.Append(fmt.Errorf("Failed to generate diff for %s: %s", path, err))
|
|
return diags
|
|
}
|
|
w.Write(diff)
|
|
}
|
|
}
|
|
|
|
if !c.list && !c.write && !c.diff {
|
|
_, err = w.Write(result)
|
|
if err != nil {
|
|
diags = diags.Append(fmt.Errorf("Failed to write result"))
|
|
}
|
|
}
|
|
|
|
return diags
|
|
}
|
|
|
|
func (c *FmtCommand) processDir(path string, stdout io.Writer) tfdiags.Diagnostics {
|
|
var diags tfdiags.Diagnostics
|
|
|
|
log.Printf("[TRACE] terraform fmt: looking for files in %s", path)
|
|
|
|
entries, err := ioutil.ReadDir(path)
|
|
if err != nil {
|
|
switch {
|
|
case os.IsNotExist(err):
|
|
diags = diags.Append(fmt.Errorf("There is no configuration directory at %s", path))
|
|
default:
|
|
// ReadDir does not produce error messages that are end-user-appropriate,
|
|
// so we'll need to simplify here.
|
|
diags = diags.Append(fmt.Errorf("Cannot read directory %s", path))
|
|
}
|
|
return diags
|
|
}
|
|
|
|
for _, info := range entries {
|
|
name := info.Name()
|
|
if configs.IsIgnoredFile(name) {
|
|
continue
|
|
}
|
|
subPath := filepath.Join(path, name)
|
|
if info.IsDir() {
|
|
if c.recursive {
|
|
subDiags := c.processDir(subPath, stdout)
|
|
diags = diags.Append(subDiags)
|
|
}
|
|
|
|
// We do not recurse into child directories by default because we
|
|
// want to mimic the file-reading behavior of "terraform plan", etc,
|
|
// operating on one module at a time.
|
|
continue
|
|
}
|
|
|
|
ext := filepath.Ext(name)
|
|
switch ext {
|
|
case ".tf", ".tfvars":
|
|
f, err := os.Open(subPath)
|
|
if err != nil {
|
|
// Open does not produce error messages that are end-user-appropriate,
|
|
// so we'll need to simplify here.
|
|
diags = diags.Append(fmt.Errorf("Failed to read file %s", subPath))
|
|
continue
|
|
}
|
|
|
|
fileDiags := c.processFile(c.normalizePath(subPath), f, stdout, false)
|
|
diags = diags.Append(fileDiags)
|
|
f.Close()
|
|
}
|
|
}
|
|
|
|
return diags
|
|
}
|
|
|
|
// formatSourceCode is the formatting logic itself, applied to each file that
|
|
// is selected (directly or indirectly) on the command line.
|
|
func (c *FmtCommand) formatSourceCode(src []byte, filename string) []byte {
|
|
f, diags := hclwrite.ParseConfig(src, filename, hcl.InitialPos)
|
|
if diags.HasErrors() {
|
|
// It would be weird to get here because the caller should already have
|
|
// checked for syntax errors and returned them. We'll just do nothing
|
|
// in this case, returning the input exactly as given.
|
|
return src
|
|
}
|
|
|
|
c.formatBody(f.Body(), nil)
|
|
|
|
return f.Bytes()
|
|
}
|
|
|
|
func (c *FmtCommand) formatBody(body *hclwrite.Body, inBlocks []string) {
|
|
attrs := body.Attributes()
|
|
for name, attr := range attrs {
|
|
if len(inBlocks) == 1 && inBlocks[0] == "variable" && name == "type" {
|
|
cleanedExprTokens := c.formatTypeExpr(attr.Expr().BuildTokens(nil))
|
|
body.SetAttributeRaw(name, cleanedExprTokens)
|
|
continue
|
|
}
|
|
cleanedExprTokens := c.formatValueExpr(attr.Expr().BuildTokens(nil))
|
|
body.SetAttributeRaw(name, cleanedExprTokens)
|
|
}
|
|
|
|
blocks := body.Blocks()
|
|
for _, block := range blocks {
|
|
// Normalize the label formatting, removing any weird stuff like
|
|
// interleaved inline comments and using the idiomatic quoted
|
|
// label syntax.
|
|
block.SetLabels(block.Labels())
|
|
|
|
inBlocks := append(inBlocks, block.Type())
|
|
c.formatBody(block.Body(), inBlocks)
|
|
}
|
|
}
|
|
|
|
func (c *FmtCommand) formatValueExpr(tokens hclwrite.Tokens) hclwrite.Tokens {
|
|
if len(tokens) < 5 {
|
|
// Can't possibly be a "${ ... }" sequence without at least enough
|
|
// tokens for the delimiters and one token inside them.
|
|
return tokens
|
|
}
|
|
oQuote := tokens[0]
|
|
oBrace := tokens[1]
|
|
cBrace := tokens[len(tokens)-2]
|
|
cQuote := tokens[len(tokens)-1]
|
|
if oQuote.Type != hclsyntax.TokenOQuote || oBrace.Type != hclsyntax.TokenTemplateInterp || cBrace.Type != hclsyntax.TokenTemplateSeqEnd || cQuote.Type != hclsyntax.TokenCQuote {
|
|
// Not an interpolation sequence at all, then.
|
|
return tokens
|
|
}
|
|
|
|
inside := tokens[2 : len(tokens)-2]
|
|
|
|
// We're only interested in sequences that are provable to be single
|
|
// interpolation sequences, which we'll determine by hunting inside
|
|
// the interior tokens for any other interpolation sequences. This is
|
|
// likely to produce false negatives sometimes, but that's better than
|
|
// false positives and we're mainly interested in catching the easy cases
|
|
// here.
|
|
quotes := 0
|
|
for _, token := range inside {
|
|
if token.Type == hclsyntax.TokenOQuote {
|
|
quotes++
|
|
continue
|
|
}
|
|
if token.Type == hclsyntax.TokenCQuote {
|
|
quotes--
|
|
continue
|
|
}
|
|
if quotes > 0 {
|
|
// Interpolation sequences inside nested quotes are okay, because
|
|
// they are part of a nested expression.
|
|
// "${foo("${bar}")}"
|
|
continue
|
|
}
|
|
if token.Type == hclsyntax.TokenTemplateInterp || token.Type == hclsyntax.TokenTemplateSeqEnd {
|
|
// We've found another template delimiter within our interior
|
|
// tokens, which suggests that we've found something like this:
|
|
// "${foo}${bar}"
|
|
// That isn't unwrappable, so we'll leave the whole expression alone.
|
|
return tokens
|
|
}
|
|
if token.Type == hclsyntax.TokenQuotedLit {
|
|
// If there's any literal characters in the outermost
|
|
// quoted sequence then it is not unwrappable.
|
|
return tokens
|
|
}
|
|
}
|
|
|
|
// If we got down here without an early return then this looks like
|
|
// an unwrappable sequence, but we'll trim any leading and trailing
|
|
// newlines that might result in an invalid result if we were to
|
|
// naively trim something like this:
|
|
// "${
|
|
// foo
|
|
// }"
|
|
return c.trimNewlines(inside)
|
|
}
|
|
|
|
func (c *FmtCommand) formatTypeExpr(tokens hclwrite.Tokens) hclwrite.Tokens {
|
|
switch len(tokens) {
|
|
case 1:
|
|
kwTok := tokens[0]
|
|
if kwTok.Type != hclsyntax.TokenIdent {
|
|
// Not a single type keyword, then.
|
|
return tokens
|
|
}
|
|
|
|
// Collection types without an explicit element type mean
|
|
// the element type is "any", so we'll normalize that.
|
|
switch string(kwTok.Bytes) {
|
|
case "list", "map", "set":
|
|
return hclwrite.Tokens{
|
|
kwTok,
|
|
{
|
|
Type: hclsyntax.TokenOParen,
|
|
Bytes: []byte("("),
|
|
},
|
|
{
|
|
Type: hclsyntax.TokenIdent,
|
|
Bytes: []byte("any"),
|
|
},
|
|
{
|
|
Type: hclsyntax.TokenCParen,
|
|
Bytes: []byte(")"),
|
|
},
|
|
}
|
|
default:
|
|
return tokens
|
|
}
|
|
|
|
case 3:
|
|
// A pre-0.12 legacy quoted string type, like "string".
|
|
oQuote := tokens[0]
|
|
strTok := tokens[1]
|
|
cQuote := tokens[2]
|
|
if oQuote.Type != hclsyntax.TokenOQuote || strTok.Type != hclsyntax.TokenQuotedLit || cQuote.Type != hclsyntax.TokenCQuote {
|
|
// Not a quoted string sequence, then.
|
|
return tokens
|
|
}
|
|
|
|
// Because this quoted syntax is from Terraform 0.11 and
|
|
// earlier, which didn't have the idea of "any" as an,
|
|
// element type, we use string as the default element
|
|
// type. That will avoid oddities if somehow the configuration
|
|
// was relying on numeric values being auto-converted to
|
|
// string, as 0.11 would do. This mimicks what terraform
|
|
// 0.12upgrade used to do, because we'd found real-world
|
|
// modules that were depending on the auto-stringing.)
|
|
switch string(strTok.Bytes) {
|
|
case "string":
|
|
return hclwrite.Tokens{
|
|
{
|
|
Type: hclsyntax.TokenIdent,
|
|
Bytes: []byte("string"),
|
|
},
|
|
}
|
|
case "list":
|
|
return hclwrite.Tokens{
|
|
{
|
|
Type: hclsyntax.TokenIdent,
|
|
Bytes: []byte("list"),
|
|
},
|
|
{
|
|
Type: hclsyntax.TokenOParen,
|
|
Bytes: []byte("("),
|
|
},
|
|
{
|
|
Type: hclsyntax.TokenIdent,
|
|
Bytes: []byte("string"),
|
|
},
|
|
{
|
|
Type: hclsyntax.TokenCParen,
|
|
Bytes: []byte(")"),
|
|
},
|
|
}
|
|
case "map":
|
|
return hclwrite.Tokens{
|
|
{
|
|
Type: hclsyntax.TokenIdent,
|
|
Bytes: []byte("map"),
|
|
},
|
|
{
|
|
Type: hclsyntax.TokenOParen,
|
|
Bytes: []byte("("),
|
|
},
|
|
{
|
|
Type: hclsyntax.TokenIdent,
|
|
Bytes: []byte("string"),
|
|
},
|
|
{
|
|
Type: hclsyntax.TokenCParen,
|
|
Bytes: []byte(")"),
|
|
},
|
|
}
|
|
default:
|
|
// Something else we're not expecting, then.
|
|
return tokens
|
|
}
|
|
default:
|
|
return tokens
|
|
}
|
|
}
|
|
|
|
func (c *FmtCommand) trimNewlines(tokens hclwrite.Tokens) hclwrite.Tokens {
|
|
if len(tokens) == 0 {
|
|
return nil
|
|
}
|
|
var start, end int
|
|
for start = 0; start < len(tokens); start++ {
|
|
if tokens[start].Type != hclsyntax.TokenNewline {
|
|
break
|
|
}
|
|
}
|
|
for end = len(tokens); end > 0; end-- {
|
|
if tokens[end-1].Type != hclsyntax.TokenNewline {
|
|
break
|
|
}
|
|
}
|
|
return tokens[start:end]
|
|
}
|
|
|
|
func (c *FmtCommand) Help() string {
|
|
helpText := `
|
|
Usage: terraform [global options] fmt [options] [DIR]
|
|
|
|
Rewrites all Terraform configuration files to a canonical format. Both
|
|
configuration files (.tf) and variables files (.tfvars) are updated.
|
|
JSON files (.tf.json or .tfvars.json) are not modified.
|
|
|
|
If DIR is not specified then the current working directory will be used.
|
|
If DIR is "-" then content will be read from STDIN. The given content must
|
|
be in the Terraform language native syntax; JSON is not supported.
|
|
|
|
Options:
|
|
|
|
-list=false Don't list files whose formatting differs
|
|
(always disabled if using STDIN)
|
|
|
|
-write=false Don't write to source files
|
|
(always disabled if using STDIN or -check)
|
|
|
|
-diff Display diffs of formatting changes
|
|
|
|
-check Check if the input is formatted. Exit status will be 0 if all
|
|
input is properly formatted and non-zero otherwise.
|
|
|
|
-no-color If specified, output won't contain any color.
|
|
|
|
-recursive Also process files in subdirectories. By default, only the
|
|
given directory (or current directory) is processed.
|
|
`
|
|
return strings.TrimSpace(helpText)
|
|
}
|
|
|
|
func (c *FmtCommand) Synopsis() string {
|
|
return "Reformat your configuration in the standard style"
|
|
}
|
|
|
|
func bytesDiff(b1, b2 []byte, path string) (data []byte, err error) {
|
|
f1, err := ioutil.TempFile("", "")
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer os.Remove(f1.Name())
|
|
defer f1.Close()
|
|
|
|
f2, err := ioutil.TempFile("", "")
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer os.Remove(f2.Name())
|
|
defer f2.Close()
|
|
|
|
f1.Write(b1)
|
|
f2.Write(b2)
|
|
|
|
data, err = exec.Command("diff", "--label=old/"+path, "--label=new/"+path, "-u", f1.Name(), f2.Name()).CombinedOutput()
|
|
if len(data) > 0 {
|
|
// diff exits with a non-zero status when the files don't match.
|
|
// Ignore that failure as long as we get output.
|
|
err = nil
|
|
}
|
|
return
|
|
}
|