From 6e2fabb2a46812dee42842c13439e1a0238aa40b Mon Sep 17 00:00:00 2001
From: Matthew Holt <mholt@users.noreply.github.com>
Date: Sun, 22 Mar 2020 22:58:24 -0600
Subject: [PATCH] cmd: Add --watch flag to start & run commands (closes #1806)

Because, just for fun.
---
 cmd/commandfuncs.go | 18 +++++++--
 cmd/commands.go     | 14 +++++--
 cmd/main.go         | 92 +++++++++++++++++++++++++++++++++++++++++----
 3 files changed, 110 insertions(+), 14 deletions(-)

diff --git a/cmd/commandfuncs.go b/cmd/commandfuncs.go
index 65c02d894..e76d7f10b 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 d4edc66f2..43aba01bf 100644
--- a/cmd/commands.go
+++ b/cmd/commands.go
@@ -74,7 +74,7 @@ func init() {
 	RegisterCommand(Command{
 		Name:  "start",
 		Func:  cmdStart,
-		Usage: "[--config <path> [[--adapter <name>]]",
+		Usage: "[--config <path> [--adapter <name>]] [--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 <path> [--adapter <name>]] [--environ]",
+		Usage: "[--config <path> [--adapter <name>]] [--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 1a3397e5e..4dbbe3833 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