diff --git a/.changelog/14779.txt b/.changelog/14779.txt new file mode 100644 index 000000000..56fc7a153 --- /dev/null +++ b/.changelog/14779.txt @@ -0,0 +1,3 @@ +```release-note:improvement +cli: add nomad fmt to the CLI +``` diff --git a/GNUmakefile b/GNUmakefile index a5eba3548..0ab6bc6fb 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -243,6 +243,7 @@ hclfmt: ## Format HCL files with hclfmt -o -name '.next' -prune \ -o -path './ui/dist' -prune \ -o -path './website/out' -prune \ + -o -path './command/testdata' -prune \ -o \( -name '*.nomad' -o -name '*.hcl' -o -name '*.tf' \) \ -print0 | xargs -0 hclfmt -w diff --git a/command/commands.go b/command/commands.go index 34da7ad90..e65914ed9 100644 --- a/command/commands.go +++ b/command/commands.go @@ -325,6 +325,11 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory { Meta: meta, }, nil }, + "fmt": func() (cli.Command, error) { + return &FormatCommand{ + Meta: meta, + }, nil + }, "fs": func() (cli.Command, error) { return &AllocFSCommand{ Meta: meta, diff --git a/command/fmt.go b/command/fmt.go new file mode 100644 index 000000000..c7e61cc20 --- /dev/null +++ b/command/fmt.go @@ -0,0 +1,269 @@ +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 = "" +) + +type FormatCommand struct { + Meta + + diagWr hcl.DiagnosticWriter + + parser *hclparse.Parser + hclDiags hcl.Diagnostics + + errs *multierror.Error + + list bool + check bool + recursive bool + write 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: + + -list=false + Don't list the files, which contain formatting inconsistencies. + + -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. + + -write=false + Don't overwrite the input files. + + -recursive + Process also files in subdirectories. By default only the given (or current) directory is processed. +` + + 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.write, "write", true, "") + flags.BoolVar(&f.list, "list", true, "") + flags.BoolVar(&f.recursive, "recursive", false, "") + + if err := flags.Parse(args); err != nil { + return 1 + } + + 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.write = false + f.list = false + } else { + f.paths = flags.Args() + } + + 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 + } + + 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.write { + if err := os.WriteFile(path, out, 0644); err != nil { + f.appendError(fmt.Errorf("Failed to write file %s: %w", path, err)) + return + } + } + } + + if !f.list && !f.write { + 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) +} diff --git a/command/fmt_test.go b/command/fmt_test.go new file mode 100644 index 000000000..8c638022f --- /dev/null +++ b/command/fmt_test.go @@ -0,0 +1,169 @@ +package command + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/hashicorp/nomad/ci" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFmtCommand(t *testing.T) { + ci.Parallel(t) + + const inSuffix = ".in.hcl" + const expectedSuffix = ".out.hcl" + tests := []string{"nomad", "job"} + + tmpDir := t.TempDir() + + for _, testName := range tests { + t.Run(testName, func(t *testing.T) { + inFile := filepath.Join("testdata", "fmt", testName+inSuffix) + expectedFile := filepath.Join("testdata", "fmt", testName+expectedSuffix) + fmtFile := filepath.Join(tmpDir, testName+".hcl") + + input, err := os.ReadFile(inFile) + require.NoError(t, err) + + expected, err := os.ReadFile(expectedFile) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(fmtFile, input, 0644)) + + ui := cli.NewMockUi() + cmd := &FormatCommand{ + Meta: Meta{Ui: ui}, + } + + code := cmd.Run([]string{fmtFile}) + assert.Equal(t, 0, code) + + actual, err := os.ReadFile(fmtFile) + require.NoError(t, err) + + assert.Equal(t, string(expected), string(actual)) + }) + } +} + +func TestFmtCommand_FromStdin(t *testing.T) { + ci.Parallel(t) + + stdinFake := bytes.NewBuffer(fmtFixture.input) + + ui := cli.NewMockUi() + cmd := &FormatCommand{ + Meta: Meta{Ui: ui}, + stdin: stdinFake, + } + + if code := cmd.Run([]string{"-"}); code != 0 { + t.Fatalf("expected code 0, got %d", code) + } + + assert.Contains(t, ui.OutputWriter.String(), string(fmtFixture.golden)) +} + +func TestFmtCommand_FromWorkingDirectory(t *testing.T) { + tmpDir := fmtFixtureWriteDir(t) + + cwd, err := os.Getwd() + require.NoError(t, err) + + err = os.Chdir(tmpDir) + require.NoError(t, err) + defer os.Chdir(cwd) + + ui := cli.NewMockUi() + cmd := &FormatCommand{ + Meta: Meta{Ui: ui}, + } + + code := cmd.Run([]string{}) + + assert.Equal(t, 0, code) + assert.Equal(t, fmt.Sprintf("%s\n", fmtFixture.filename), ui.OutputWriter.String()) +} + +func TestFmtCommand_FromDirectoryArgument(t *testing.T) { + tmpDir := fmtFixtureWriteDir(t) + + ui := cli.NewMockUi() + cmd := &FormatCommand{ + Meta: Meta{Ui: ui}, + } + + code := cmd.Run([]string{tmpDir}) + + assert.Equal(t, 0, code) + assert.Equal(t, fmt.Sprintf("%s\n", filepath.Join(tmpDir, fmtFixture.filename)), ui.OutputWriter.String()) +} + +func TestFmtCommand_FromFileArgument(t *testing.T) { + tmpDir := fmtFixtureWriteDir(t) + + ui := cli.NewMockUi() + cmd := &FormatCommand{ + Meta: Meta{Ui: ui}, + } + + path := filepath.Join(tmpDir, fmtFixture.filename) + + code := cmd.Run([]string{path}) + + assert.Equal(t, 0, code) + assert.Equal(t, fmt.Sprintf("%s\n", path), ui.OutputWriter.String()) +} + +func TestFmtCommand_FileDoesNotExist(t *testing.T) { + ci.Parallel(t) + + ui := cli.NewMockUi() + cmd := &FormatCommand{ + Meta: Meta{Ui: ui}, + } + + code := cmd.Run([]string{"file/does/not/exist.hcl"}) + assert.Equal(t, 1, code) +} + +func TestFmtCommand_InvalidSyntax(t *testing.T) { + ci.Parallel(t) + + stdinFake := bytes.NewBufferString(`client {enabled true }`) + + ui := cli.NewMockUi() + cmd := &FormatCommand{ + Meta: Meta{Ui: ui}, + stdin: stdinFake, + } + + code := cmd.Run([]string{"-"}) + assert.Equal(t, 1, code) +} + +func fmtFixtureWriteDir(t *testing.T) string { + dir := t.TempDir() + + err := ioutil.WriteFile(filepath.Join(dir, fmtFixture.filename), fmtFixture.input, 0644) + require.NoError(t, err) + + return dir +} + +var fmtFixture = struct { + filename string + input []byte + golden []byte +}{ + filename: "nomad.hcl", + input: []byte(`client {enabled = true}`), + golden: []byte(`client { enabled = true }`), +} diff --git a/command/testdata/fmt/job.in.hcl b/command/testdata/fmt/job.in.hcl new file mode 100644 index 000000000..146b863b8 --- /dev/null +++ b/command/testdata/fmt/job.in.hcl @@ -0,0 +1,17 @@ + job "job1" { + type = "service" + datacenters = [ "dc1" ] + group "group1" { + count = 1 + task "task1" { + driver = "exec" + config { + command = "/bin/sleep" + } + resources { + cpu = 1000 + memory = 512 + } +} +} +} diff --git a/command/testdata/fmt/job.out.hcl b/command/testdata/fmt/job.out.hcl new file mode 100644 index 000000000..bd3727eaa --- /dev/null +++ b/command/testdata/fmt/job.out.hcl @@ -0,0 +1,17 @@ +job "job1" { + type = "service" + datacenters = ["dc1"] + group "group1" { + count = 1 + task "task1" { + driver = "exec" + config { + command = "/bin/sleep" + } + resources { + cpu = 1000 + memory = 512 + } + } + } +} diff --git a/command/testdata/fmt/nomad.in.hcl b/command/testdata/fmt/nomad.in.hcl new file mode 100644 index 000000000..e2590998e --- /dev/null +++ b/command/testdata/fmt/nomad.in.hcl @@ -0,0 +1,8 @@ +server { + enabled = true + bootstrap_expect = 3 +} + +consul { + address = "1.2.3.4:8500" +} diff --git a/command/testdata/fmt/nomad.out.hcl b/command/testdata/fmt/nomad.out.hcl new file mode 100644 index 000000000..cf2fe6ab5 --- /dev/null +++ b/command/testdata/fmt/nomad.out.hcl @@ -0,0 +1,8 @@ +server { + enabled = true + bootstrap_expect = 3 +} + +consul { + address = "1.2.3.4:8500" +} diff --git a/website/content/docs/commands/fmt.mdx b/website/content/docs/commands/fmt.mdx new file mode 100644 index 000000000..2788805d8 --- /dev/null +++ b/website/content/docs/commands/fmt.mdx @@ -0,0 +1,60 @@ +--- +layout: docs +page_title: 'Commands: fmt' +description: | + Rewrite Nomad config and job files to canonical format +--- + +# Command: fmt + +The `fmt` commands check the syntax and rewrites Nomad configuration and jobspec +files to canonical format. It can be used to improve readability and enforce +consistency of style in Nomad files. + +## Usage + +```plaintext +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: + +- `-list=false` : Don't list the files, which contain formatting inconsistencies. +- `-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. +- `-write=false` : Don't overwrite the input files. +- `-recursive` : Process also files in subdirectories. By default only the given (or current) directory is processed. + +## Examples + +```shell-session +$ cat agent.hcl +server { + enabled = true + bootstrap_expect = 1 +} + +client { + enabled = true +} + +$ nomad fmt + +agent.hcl +$ cat agent.hcl +server { + enabled = true + bootstrap_expect = 1 +} + +client { + enabled = true +} +``` diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index f9b3a8652..398f1abc1 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -429,6 +429,10 @@ } ] }, + { + "title": "fmt", + "path": "commands/fmt" + }, { "title": "job", "routes": [