open-nomad/command/fmt.go
Tim Gross 4fabad7f61
cli: fmt -check should return early on diff (#16174)
The `nomad fmt -check` command incorrectly writes to file because we didn't
return before writing the file on a diff. Fix this bug and update the command
internals to differentiate between the write-to-file and write-to-stdout code
paths, which are activated by different combinations of options and flags.

The docstring for the `-list` and `-write` flags is also unclear and can be
easily misread to be the opposite of the actual behavior. Clarify this and fix
up the docs to match.

This changeset also refactors the tests quite a bit so as to make the test
outputs clear when something is incorrect.
2023-02-15 14:06:31 -05:00

291 lines
5.9 KiB
Go

package command
import (
"bytes"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/hashicorp/go-multierror"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclparse"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/hcl/v2/hclwrite"
"github.com/posener/complete"
"golang.org/x/crypto/ssh/terminal"
)
const (
stdinArg = "-"
stdinPath = "<stdin>"
)
type FormatCommand struct {
Meta
diagWr hcl.DiagnosticWriter
parser *hclparse.Parser
hclDiags hcl.Diagnostics
errs *multierror.Error
list bool
check bool
checkSuccess bool
recursive bool
writeFile bool
writeStdout bool
paths []string
stdin io.Reader
}
func (*FormatCommand) Help() string {
helpText := `
Usage: nomad fmt [flags] paths ...
Formats Nomad agent configuration and job file to a canonical format.
If a path is a directory, it will recursively format all files
with .nomad and .hcl extensions in the directory.
If you provide a single dash (-) as argument, fmt will read from standard
input (STDIN) and output the processed output to standard output (STDOUT).
Format Options:
-check
Check if the files are valid HCL files. If not, exit status of the
command will be 1 and the incorrect files will not be formatted. This
flag overrides any -write flag value.
-list
List the files which contain formatting inconsistencies. Defaults
to -list=true.
-recursive
Process files in subdirectories. By default only the given (or current)
directory is processed.
-write
Overwrite the input files. Defaults to -write=true. Ignored if the input
comes from STDIN.
`
return strings.TrimSpace(helpText)
}
func (*FormatCommand) Synopsis() string {
return "Rewrites Nomad config and job files to canonical format"
}
func (*FormatCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictFiles("*")
}
func (*FormatCommand) AutocompleteFlags() complete.Flags {
return complete.Flags{
"-check": complete.PredictNothing,
"-write": complete.PredictNothing,
"-list": complete.PredictNothing,
"-recursive": complete.PredictNothing,
}
}
func (f *FormatCommand) Name() string { return "fmt" }
func (f *FormatCommand) Run(args []string) int {
if f.stdin == nil {
f.stdin = os.Stdin
}
flags := f.Meta.FlagSet(f.Name(), FlagSetClient)
flags.Usage = func() { f.Ui.Output(f.Help()) }
flags.BoolVar(&f.check, "check", false, "")
flags.BoolVar(&f.writeFile, "write", true, "")
flags.BoolVar(&f.list, "list", true, "")
flags.BoolVar(&f.recursive, "recursive", false, "")
if err := flags.Parse(args); err != nil {
return 1
}
f.checkSuccess = true
f.parser = hclparse.NewParser()
color := terminal.IsTerminal(int(os.Stderr.Fd()))
w, _, err := terminal.GetSize(int(os.Stdout.Fd()))
if err != nil {
w = 80
}
f.diagWr = hcl.NewDiagnosticTextWriter(os.Stderr, f.parser.Files(), uint(w), color)
if len(flags.Args()) == 0 {
f.paths = []string{"."}
} else if flags.Args()[0] == stdinArg {
f.writeStdout = true
f.writeFile = false
f.list = false
} else {
f.paths = flags.Args()
}
if f.check {
f.writeFile = false
}
if !f.list && !f.writeFile {
f.writeStdout = true
}
f.fmt()
if f.hclDiags.HasErrors() {
f.diagWr.WriteDiagnostics(f.hclDiags)
}
if f.errs != nil {
f.Ui.Error(f.errs.Error())
f.Ui.Error(commandErrorText(f))
}
if f.hclDiags.HasErrors() || f.errs != nil {
return 1
}
if !f.checkSuccess {
return 1
}
return 0
}
func (f *FormatCommand) fmt() {
if len(f.paths) == 0 {
f.processFile(stdinPath, f.stdin)
return
}
for _, path := range f.paths {
info, err := os.Stat(path)
if err != nil {
f.appendError(fmt.Errorf("No file or directory at %s", path))
continue
}
if info.IsDir() {
f.processDir(path)
} else {
if isNomadFile(info) {
fp, err := os.Open(path)
if err != nil {
f.appendError(fmt.Errorf("Failed to open file %s: %w", path, err))
continue
}
f.processFile(path, fp)
fp.Close()
} else {
f.appendError(fmt.Errorf("Only .nomad and .hcl files can be processed using nomad fmt"))
continue
}
}
}
}
func (f *FormatCommand) processDir(path string) {
entries, err := os.ReadDir(path)
if err != nil {
f.appendError(fmt.Errorf("Failed to list directory %s", path))
return
}
for _, entry := range entries {
name := entry.Name()
subpath := filepath.Join(path, name)
if entry.IsDir() {
if f.recursive {
f.processDir(subpath)
}
continue
}
info, err := entry.Info()
if err != nil {
f.appendError(err)
continue
}
if isNomadFile(info) {
fp, err := os.Open(subpath)
if err != nil {
f.appendError(fmt.Errorf("Failed to open file %s: %w", path, err))
continue
}
f.processFile(subpath, fp)
fp.Close()
}
}
}
func (f *FormatCommand) processFile(path string, r io.Reader) {
src, err := io.ReadAll(r)
if err != nil {
f.appendError(fmt.Errorf("Failed to read file %s: %w", path, err))
return
}
f.parser.AddFile(path, &hcl.File{
Body: hcl.EmptyBody(),
Bytes: src,
})
_, syntaxDiags := hclsyntax.ParseConfig(src, path, hcl.InitialPos)
if syntaxDiags.HasErrors() {
f.hclDiags = append(f.hclDiags, syntaxDiags...)
return
}
formattedFile, diags := hclwrite.ParseConfig(src, path, hcl.InitialPos)
if diags.HasErrors() {
f.hclDiags = append(f.hclDiags, diags...)
return
}
out := formattedFile.Bytes()
if !bytes.Equal(src, out) {
if f.list {
f.Ui.Output(path)
}
if f.check {
f.checkSuccess = false
}
if f.writeFile {
if err := os.WriteFile(path, out, 0644); err != nil {
f.appendError(fmt.Errorf("Failed to write file %s: %w", path, err))
return
}
}
}
if f.writeStdout {
f.Ui.Output(string(out))
}
}
func isNomadFile(file fs.FileInfo) bool {
return !file.IsDir() && (filepath.Ext(file.Name()) == ".nomad" || filepath.Ext(file.Name()) == ".hcl")
}
func (f *FormatCommand) appendError(err error) {
f.errs = multierror.Append(f.errs, err)
}