cmd: Add --watch flag to start & run commands (closes #1806)

Because, just for fun.
This commit is contained in:
Matthew Holt 2020-03-22 22:58:24 -06:00
parent 8cc60e6896
commit 6e2fabb2a4
3 changed files with 110 additions and 14 deletions

View file

@ -42,6 +42,7 @@ import (
func cmdStart(fl Flags) (int, error) { func cmdStart(fl Flags) (int, error) {
startCmdConfigFlag := fl.String("config") startCmdConfigFlag := fl.String("config")
startCmdConfigAdapterFlag := fl.String("adapter") startCmdConfigAdapterFlag := fl.String("adapter")
startCmdWatchFlag := fl.Bool("watch")
// open a listener to which the child process will connect when // open a listener to which the child process will connect when
// it is ready to confirm that it has successfully started // it is ready to confirm that it has successfully started
@ -68,6 +69,9 @@ func cmdStart(fl Flags) (int, error) {
if startCmdConfigAdapterFlag != "" { if startCmdConfigAdapterFlag != "" {
cmd.Args = append(cmd.Args, "--adapter", startCmdConfigAdapterFlag) cmd.Args = append(cmd.Args, "--adapter", startCmdConfigAdapterFlag)
} }
if startCmdWatchFlag {
cmd.Args = append(cmd.Args, "--watch")
}
stdinpipe, err := cmd.StdinPipe() stdinpipe, err := cmd.StdinPipe()
if err != nil { if err != nil {
return caddy.ExitCodeFailedStartup, return caddy.ExitCodeFailedStartup,
@ -145,6 +149,7 @@ func cmdRun(fl Flags) (int, error) {
runCmdConfigAdapterFlag := fl.String("adapter") runCmdConfigAdapterFlag := fl.String("adapter")
runCmdResumeFlag := fl.Bool("resume") runCmdResumeFlag := fl.Bool("resume")
runCmdPrintEnvFlag := fl.Bool("environ") runCmdPrintEnvFlag := fl.Bool("environ")
runCmdWatchFlag := fl.Bool("watch")
runCmdPingbackFlag := fl.String("pingback") runCmdPingbackFlag := fl.String("pingback")
// if we are supposed to print the environment, do that first // 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 // 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 { if !runCmdResumeFlag {
config, _, err = loadConfig(runCmdConfigFlag, runCmdConfigAdapterFlag) config, configFile, err = loadConfig(runCmdConfigFlag, runCmdConfigAdapterFlag)
if err != nil { if err != nil {
return caddy.ExitCodeFailedStartup, err 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 // warn if the environment does not provide enough information about the disk
hasXDG := os.Getenv("XDG_DATA_HOME") != "" && hasXDG := os.Getenv("XDG_DATA_HOME") != "" &&
os.Getenv("XDG_CONFIG_HOME") != "" && os.Getenv("XDG_CONFIG_HOME") != "" &&
@ -266,11 +278,11 @@ func cmdReload(fl Flags) (int, error) {
reloadCmdAddrFlag := fl.String("address") reloadCmdAddrFlag := fl.String("address")
// get the config in caddy's native format // get the config in caddy's native format
config, hasConfig, err := loadConfig(reloadCmdConfigFlag, reloadCmdConfigAdapterFlag) config, configFile, err := loadConfig(reloadCmdConfigFlag, reloadCmdConfigAdapterFlag)
if err != nil { if err != nil {
return caddy.ExitCodeFailedStartup, err return caddy.ExitCodeFailedStartup, err
} }
if !hasConfig { if configFile == "" {
return caddy.ExitCodeFailedStartup, fmt.Errorf("no config file to load") return caddy.ExitCodeFailedStartup, fmt.Errorf("no config file to load")
} }

View file

@ -74,7 +74,7 @@ func init() {
RegisterCommand(Command{ RegisterCommand(Command{
Name: "start", Name: "start",
Func: cmdStart, Func: cmdStart,
Usage: "[--config <path> [[--adapter <name>]]", Usage: "[--config <path> [--adapter <name>]] [--watch]",
Short: "Starts the Caddy process in the background and then returns", Short: "Starts the Caddy process in the background and then returns",
Long: ` Long: `
Starts the Caddy process, optionally bootstrapped with an initial config file. 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 := flag.NewFlagSet("start", flag.ExitOnError)
fs.String("config", "", "Configuration file") fs.String("config", "", "Configuration file")
fs.String("adapter", "", "Name of config adapter to apply") fs.String("adapter", "", "Name of config adapter to apply")
fs.Bool("watch", false, "Reload changed config file automatically")
return fs return fs
}(), }(),
}) })
@ -94,7 +95,7 @@ using 'caddy run' instead to keep it in the foreground.`,
RegisterCommand(Command{ RegisterCommand(Command{
Name: "run", Name: "run",
Func: cmdRun, Func: cmdRun,
Usage: "[--config <path> [--adapter <name>]] [--environ]", Usage: "[--config <path> [--adapter <name>]] [--environ] [--watch]",
Short: `Starts the Caddy process and blocks indefinitely`, Short: `Starts the Caddy process and blocks indefinitely`,
Long: ` Long: `
Starts the Caddy process, optionally bootstrapped with an initial config file, 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. not quit after printing, and can be useful for troubleshooting.
The --resume flag will override the --config flag if there is a config auto- 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 { Flags: func() *flag.FlagSet {
fs := flag.NewFlagSet("run", flag.ExitOnError) fs := flag.NewFlagSet("run", flag.ExitOnError)
fs.String("config", "", "Configuration file") fs.String("config", "", "Configuration file")
fs.String("adapter", "", "Name of config adapter to apply") 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("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") fs.String("pingback", "", "Echo confirmation bytes to this address on success")
return fs return fs
}(), }(),

View file

@ -99,10 +99,10 @@ func handlePingbackConn(conn net.Conn, expect []byte) error {
// there is no config available. It prints any warnings to stderr, // there is no config available. It prints any warnings to stderr,
// and returns the resulting JSON config bytes along with // and returns the resulting JSON config bytes along with
// whether a config file was loaded or not. // 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 // specifying an adapter without a config file is ambiguous
if adapterName != "" && configFile == "" { 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 // load initial config and adapter
@ -112,7 +112,7 @@ func loadConfig(configFile, adapterName string) ([]byte, bool, error) {
if configFile != "" { if configFile != "" {
config, err = ioutil.ReadFile(configFile) config, err = ioutil.ReadFile(configFile)
if err != nil { 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", caddy.Log().Info("using provided configuration",
zap.String("config_file", configFile), zap.String("config_file", configFile),
@ -129,7 +129,7 @@ func loadConfig(configFile, adapterName string) ([]byte, bool, error) {
cfgAdapter = nil cfgAdapter = nil
} else if err != nil { } else if err != nil {
// default Caddyfile exists, but error reading it // 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 { } else {
// success reading default Caddyfile // success reading default Caddyfile
configFile = "Caddyfile" configFile = "Caddyfile"
@ -151,7 +151,7 @@ func loadConfig(configFile, adapterName string) ([]byte, bool, error) {
if adapterName != "" { if adapterName != "" {
cfgAdapter = caddyconfig.GetAdapter(adapterName) cfgAdapter = caddyconfig.GetAdapter(adapterName)
if cfgAdapter == nil { 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, "filename": configFile,
}) })
if err != nil { 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 { for _, warn := range warnings {
msg := warn.Message msg := warn.Message
@ -173,7 +173,85 @@ func loadConfig(configFile, adapterName string) ([]byte, bool, error) {
config = adaptedConfig 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 // Flags wraps a FlagSet so that typed values