diff --git a/command/agent/command.go b/command/agent/command.go index 558e63ee120e..fe8d5c76b159 100644 --- a/command/agent/command.go +++ b/command/agent/command.go @@ -294,14 +294,14 @@ func (c *Command) readConfig() *Config { config.Server.DefaultSchedulerConfig.Canonicalize() - if !c.isValidConfig(config, cmdConfig) { + if !c.IsValidConfig(config, cmdConfig) { return nil } return config } -func (c *Command) isValidConfig(config, cmdConfig *Config) bool { +func (c *Command) IsValidConfig(config, cmdConfig *Config) bool { // Check that the server is running in at least one mode. if !(config.Server.Enabled || config.Client.Enabled) { diff --git a/command/agent/command_test.go b/command/agent/command_test.go index 6b2c80618e00..e155e4c2b5b3 100644 --- a/command/agent/command_test.go +++ b/command/agent/command_test.go @@ -370,7 +370,7 @@ func TestIsValidConfig(t *testing.T) { mui := cli.NewMockUi() cmd := &Command{Ui: mui} config := DefaultConfig().Merge(&tc.conf) - result := cmd.isValidConfig(config, DefaultConfig()) + result := cmd.IsValidConfig(config, DefaultConfig()) if tc.err == "" { // No error expected assert.True(t, result, mui.ErrorWriter.String()) diff --git a/command/commands.go b/command/commands.go index 87f46b8f4ad0..016502bc41bc 100644 --- a/command/commands.go +++ b/command/commands.go @@ -204,6 +204,16 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory { Meta: meta, }, nil }, + "config": func() (cli.Command, error) { + return &ConfigCommand{ + Meta: meta, + }, nil + }, + "config validate": func() (cli.Command, error) { + return &ConfigValidateCommand{ + Meta: meta, + }, nil + }, // operator debug was released in 0.12 as debug. This top-level alias preserves compatibility "debug": func() (cli.Command, error) { return &OperatorDebugCommand{ diff --git a/command/config.go b/command/config.go new file mode 100644 index 000000000000..16b47d578d2e --- /dev/null +++ b/command/config.go @@ -0,0 +1,38 @@ +package command + +import ( + "strings" + + "github.com/mitchellh/cli" +) + +type ConfigCommand struct { + Meta +} + +func (f *ConfigCommand) Help() string { + helpText := ` +Usage: nomad config [options] [args] + + This command groups subcommands for interacting with configurations. + Users can validate configurations for the Nomad agent. + + Validate configuration: + + $ nomad config validate [...] + + Please see the individual subcommand help for detailed usage information. +` + + return strings.TrimSpace(helpText) +} + +func (f *ConfigCommand) Synopsis() string { + return "Interact with configurations" +} + +func (f *ConfigCommand) Name() string { return "config" } + +func (f *ConfigCommand) Run(args []string) int { + return cli.RunResultHelp +} diff --git a/command/config_validate.go b/command/config_validate.go new file mode 100644 index 000000000000..3efb4d307e8d --- /dev/null +++ b/command/config_validate.go @@ -0,0 +1,89 @@ +package command + +import ( + "fmt" + "reflect" + "strings" + + multierror "github.com/hashicorp/go-multierror" + agent "github.com/hashicorp/nomad/command/agent" +) + +type ConfigValidateCommand struct { + Meta +} + +func (c *ConfigValidateCommand) Help() string { + helpText := ` +Usage: nomad config validate [] + + Perform validation on a set of Nomad configuration files. This is useful + to test the Nomad configuration without starting the agent. + + Accepts the path to either a single config file or a directory of + config files to use for configuring the Nomad agent. This option may + be specified multiple times. If multiple config files are used, the + values from each will be merged together. During merging, values from + files found later in the list are merged over values from previously + parsed files. + + This command cannot operate on partial configuration fragments since + those won't pass the full agent validation. This command does not + require an ACL token. + + Returns 0 if the configuration is valid, or 1 if there are problems. +` + + return strings.TrimSpace(helpText) +} + +func (c *ConfigValidateCommand) Synopsis() string { + return "Validate config files/directories" +} + +func (c *ConfigValidateCommand) Name() string { return "config validate" } + +func (c *ConfigValidateCommand) Run(args []string) int { + var mErr multierror.Error + flags := c.Meta.FlagSet(c.Name(), FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + if err := flags.Parse(args); err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + configPath := flags.Args() + if len(configPath) < 1 { + c.Ui.Error("Must specify at least one config file or directory") + return 1 + } + + config := agent.DefaultConfig() + + for _, path := range configPath { + fc, err := agent.LoadConfig(path) + if err != nil { + multierror.Append(&mErr, fmt.Errorf( + "Error loading configuration from %s: %s", path, err)) + continue + } + if fc == nil || reflect.DeepEqual(fc, &agent.Config{}) { + c.Ui.Warn(fmt.Sprintf("No configuration loaded from %s", path)) + } + + config = config.Merge(fc) + } + if err := mErr.ErrorOrNil(); err != nil { + c.Ui.Error(err.Error()) + return 1 + } + cmd := agent.Command{Ui: c.Ui} + valid := cmd.IsValidConfig(config, agent.DefaultConfig()) + if !valid { + c.Ui.Error("Configuration is invalid") + return 1 + } + + c.Ui.Output("Configuration is valid!") + return 0 +} diff --git a/command/config_validate_test.go b/command/config_validate_test.go new file mode 100644 index 000000000000..80ac91fe1e93 --- /dev/null +++ b/command/config_validate_test.go @@ -0,0 +1,106 @@ +package command + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/mitchellh/cli" +) + +func TestConfigValidateCommand_FailWithEmptyDir(t *testing.T) { + t.Parallel() + fh, err := ioutil.TempDir("", "nomad") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(fh) + + ui := cli.NewMockUi() + cmd := &ConfigValidateCommand{Meta: Meta{Ui: ui}} + args := []string{fh} + + code := cmd.Run(args) + if code != 1 { + t.Fatalf("expected exit 1, actual: %d", code) + } +} + +func TestConfigValidateCommand_SucceedWithMinimalConfigFile(t *testing.T) { + t.Parallel() + fh, err := ioutil.TempDir("", "nomad") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(fh) + + fp := filepath.Join(fh, "config.hcl") + err = ioutil.WriteFile(fp, []byte(`data_dir="/" + client { + enabled = true + }`), 0644) + if err != nil { + t.Fatalf("err: %s", err) + } + + ui := cli.NewMockUi() + cmd := &ConfigValidateCommand{Meta: Meta{Ui: ui}} + args := []string{fh} + + code := cmd.Run(args) + if code != 0 { + t.Fatalf("expected exit 0, actual: %d", code) + } +} + +func TestConfigValidateCommand_FailOnParseBadConfigFile(t *testing.T) { + t.Parallel() + fh, err := ioutil.TempDir("", "nomad") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(fh) + + fp := filepath.Join(fh, "config.hcl") + err = ioutil.WriteFile(fp, []byte(`a: b`), 0644) + if err != nil { + t.Fatalf("err: %s", err) + } + + ui := cli.NewMockUi() + cmd := &ConfigValidateCommand{Meta: Meta{Ui: ui}} + args := []string{fh} + + code := cmd.Run(args) + if code != 1 { + t.Fatalf("expected exit 1, actual: %d", code) + } +} + +func TestConfigValidateCommand_FailOnValidateParsableConfigFile(t *testing.T) { + t.Parallel() + fh, err := ioutil.TempDir("", "nomad") + if err != nil { + t.Fatalf("err: %s", err) + } + defer os.Remove(fh) + + fp := filepath.Join(fh, "config.hcl") + err = ioutil.WriteFile(fp, []byte(`data_dir="../" + client { + enabled = true + }`), 0644) + if err != nil { + t.Fatalf("err: %s", err) + } + + ui := cli.NewMockUi() + cmd := &ConfigValidateCommand{Meta: Meta{Ui: ui}} + args := []string{fh} + + code := cmd.Run(args) + if code != 1 { + t.Fatalf("expected exit 1, actual: %d", code) + } +}