diff --git a/cmd/commandfuncs.go b/cmd/commandfuncs.go index 65c02d89..e76d7f10 100644 --- a/cmd/commandfuncs.go +++ b/cmd/commandfuncs.go @@ -42,6 +42,7 @@ import ( func cmdStart(fl Flags) (int, error) { startCmdConfigFlag := fl.String("config") startCmdConfigAdapterFlag := fl.String("adapter") + startCmdWatchFlag := fl.Bool("watch") // open a listener to which the child process will connect when // it is ready to confirm that it has successfully started @@ -68,6 +69,9 @@ func cmdStart(fl Flags) (int, error) { if startCmdConfigAdapterFlag != "" { cmd.Args = append(cmd.Args, "--adapter", startCmdConfigAdapterFlag) } + if startCmdWatchFlag { + cmd.Args = append(cmd.Args, "--watch") + } stdinpipe, err := cmd.StdinPipe() if err != nil { return caddy.ExitCodeFailedStartup, @@ -145,6 +149,7 @@ func cmdRun(fl Flags) (int, error) { runCmdConfigAdapterFlag := fl.String("adapter") runCmdResumeFlag := fl.Bool("resume") runCmdPrintEnvFlag := fl.Bool("environ") + runCmdWatchFlag := fl.Bool("watch") runCmdPingbackFlag := fl.String("pingback") // if we are supposed to print the environment, do that first @@ -171,8 +176,9 @@ func cmdRun(fl Flags) (int, error) { } } // we don't use 'else' here since this value might have been changed in 'if' block; i.e. not mutually exclusive + var configFile string if !runCmdResumeFlag { - config, _, err = loadConfig(runCmdConfigFlag, runCmdConfigAdapterFlag) + config, configFile, err = loadConfig(runCmdConfigFlag, runCmdConfigAdapterFlag) if err != nil { return caddy.ExitCodeFailedStartup, err } @@ -211,6 +217,12 @@ func cmdRun(fl Flags) (int, error) { } } + // if enabled, reload config file automatically on changes + // (this better only be used in dev!) + if runCmdWatchFlag { + go watchConfigFile(configFile, runCmdConfigAdapterFlag) + } + // warn if the environment does not provide enough information about the disk hasXDG := os.Getenv("XDG_DATA_HOME") != "" && os.Getenv("XDG_CONFIG_HOME") != "" && @@ -266,11 +278,11 @@ func cmdReload(fl Flags) (int, error) { reloadCmdAddrFlag := fl.String("address") // get the config in caddy's native format - config, hasConfig, err := loadConfig(reloadCmdConfigFlag, reloadCmdConfigAdapterFlag) + config, configFile, err := loadConfig(reloadCmdConfigFlag, reloadCmdConfigAdapterFlag) if err != nil { return caddy.ExitCodeFailedStartup, err } - if !hasConfig { + if configFile == "" { return caddy.ExitCodeFailedStartup, fmt.Errorf("no config file to load") } diff --git a/cmd/commands.go b/cmd/commands.go index d4edc66f..43aba01b 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -74,7 +74,7 @@ func init() { RegisterCommand(Command{ Name: "start", Func: cmdStart, - Usage: "[--config [[--adapter ]]", + Usage: "[--config [--adapter ]] [--watch]", Short: "Starts the Caddy process in the background and then returns", Long: ` Starts the Caddy process, optionally bootstrapped with an initial config file. @@ -87,6 +87,7 @@ using 'caddy run' instead to keep it in the foreground.`, fs := flag.NewFlagSet("start", flag.ExitOnError) fs.String("config", "", "Configuration file") fs.String("adapter", "", "Name of config adapter to apply") + fs.Bool("watch", false, "Reload changed config file automatically") return fs }(), }) @@ -94,7 +95,7 @@ using 'caddy run' instead to keep it in the foreground.`, RegisterCommand(Command{ Name: "run", Func: cmdRun, - Usage: "[--config [--adapter ]] [--environ]", + Usage: "[--config [--adapter ]] [--environ] [--watch]", Short: `Starts the Caddy process and blocks indefinitely`, Long: ` Starts the Caddy process, optionally bootstrapped with an initial config file, @@ -119,13 +120,18 @@ be printed before starting. This is the same as the environ command but does not quit after printing, and can be useful for troubleshooting. The --resume flag will override the --config flag if there is a config auto- -save file. It is not an error if --resume is used and no autosave file exists.`, +save file. It is not an error if --resume is used and no autosave file exists. + +If --watch is specified, the config file will be loaded automatically after +changes. ⚠️ This is dangerous in production! Only use this option in a local +development environment.`, Flags: func() *flag.FlagSet { fs := flag.NewFlagSet("run", flag.ExitOnError) fs.String("config", "", "Configuration file") fs.String("adapter", "", "Name of config adapter to apply") - fs.Bool("resume", false, "Use saved config, if any (and prefer over --config file)") fs.Bool("environ", false, "Print environment") + fs.Bool("resume", false, "Use saved config, if any (and prefer over --config file)") + fs.Bool("watch", false, "Watch config file for changes and reload it automatically") fs.String("pingback", "", "Echo confirmation bytes to this address on success") return fs }(), diff --git a/cmd/main.go b/cmd/main.go index 1a3397e5..4dbbe383 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -99,10 +99,10 @@ func handlePingbackConn(conn net.Conn, expect []byte) error { // there is no config available. It prints any warnings to stderr, // and returns the resulting JSON config bytes along with // whether a config file was loaded or not. -func loadConfig(configFile, adapterName string) ([]byte, bool, error) { +func loadConfig(configFile, adapterName string) ([]byte, string, error) { // specifying an adapter without a config file is ambiguous if adapterName != "" && configFile == "" { - return nil, false, fmt.Errorf("cannot adapt config without config file (use --config)") + return nil, "", fmt.Errorf("cannot adapt config without config file (use --config)") } // load initial config and adapter @@ -112,7 +112,7 @@ func loadConfig(configFile, adapterName string) ([]byte, bool, error) { if configFile != "" { config, err = ioutil.ReadFile(configFile) if err != nil { - return nil, false, fmt.Errorf("reading config file: %v", err) + return nil, "", fmt.Errorf("reading config file: %v", err) } caddy.Log().Info("using provided configuration", zap.String("config_file", configFile), @@ -129,7 +129,7 @@ func loadConfig(configFile, adapterName string) ([]byte, bool, error) { cfgAdapter = nil } else if err != nil { // default Caddyfile exists, but error reading it - return nil, false, fmt.Errorf("reading default Caddyfile: %v", err) + return nil, "", fmt.Errorf("reading default Caddyfile: %v", err) } else { // success reading default Caddyfile configFile = "Caddyfile" @@ -151,7 +151,7 @@ func loadConfig(configFile, adapterName string) ([]byte, bool, error) { if adapterName != "" { cfgAdapter = caddyconfig.GetAdapter(adapterName) if cfgAdapter == nil { - return nil, false, fmt.Errorf("unrecognized config adapter: %s", adapterName) + return nil, "", fmt.Errorf("unrecognized config adapter: %s", adapterName) } } @@ -161,7 +161,7 @@ func loadConfig(configFile, adapterName string) ([]byte, bool, error) { "filename": configFile, }) if err != nil { - return nil, false, fmt.Errorf("adapting config using %s: %v", adapterName, err) + return nil, "", fmt.Errorf("adapting config using %s: %v", adapterName, err) } for _, warn := range warnings { msg := warn.Message @@ -173,7 +173,85 @@ func loadConfig(configFile, adapterName string) ([]byte, bool, error) { config = adaptedConfig } - return config, configFile != "", nil + return config, configFile, nil +} + +// watchConfigFile watches the config file at filename for changes +// and reloads the config if the file was updated. This function +// blocks indefinitely; it only quits if the poller has errors for +// long enough time. The filename passed in must be the actual +// config file used, not one to be discovered. +func watchConfigFile(filename, adapterName string) { + // make our logger; since config reloads can change the + // default logger, we need to get it dynamically each time + logger := func() *zap.Logger { + return caddy.Log(). + Named("watcher"). + With(zap.String("config_file", filename)) + } + + // get the initial timestamp on the config file + info, err := os.Stat(filename) + if err != nil { + logger().Error("cannot watch config file", zap.Error(err)) + return + } + lastModified := info.ModTime() + + logger().Info("watching config file for changes") + + // if the file disappears or something, we can + // stop polling if the error lasts long enough + var lastErr time.Time + finalError := func(err error) bool { + if lastErr.IsZero() { + lastErr = time.Now() + return false + } + if time.Since(lastErr) > 30*time.Second { + logger().Error("giving up watching config file; too many errors", + zap.Error(err)) + return true + } + return false + } + + // begin poller + for range time.Tick(1 * time.Second) { + // get the file info + info, err := os.Stat(filename) + if err != nil { + if finalError(err) { + return + } + continue + } + lastErr = time.Time{} // no error, so clear any memory of one + + // if it hasn't changed, nothing to do + if !info.ModTime().After(lastModified) { + continue + } + + logger().Info("config file changed; reloading") + + // remember this timestamp + lastModified = info.ModTime() + + // load the contents of the file + config, _, err := loadConfig(filename, adapterName) + if err != nil { + logger().Error("unable to load latest config", zap.Error(err)) + continue + } + + // apply the updated config + err = caddy.Load(config, false) + if err != nil { + logger().Error("applying latest config", zap.Error(err)) + continue + } + } } // Flags wraps a FlagSet so that typed values