From 0006df60264e7fdc55fc1a0962109e8198fa9d71 Mon Sep 17 00:00:00 2001 From: aca Date: Tue, 1 Oct 2019 12:23:58 +0900 Subject: cmd: Refactor subcommands, add help, make them pluggable * cli: Change command structure, add help subcommand (#328) * cli: improve subcommand structure - make help command as normal subcommand - add flag usage message for each command * cmd: Refactor subcommands and command line help; make commands pluggable --- cmd/commands.go | 497 ++++++++++++++++++++------------------------------------ 1 file changed, 179 insertions(+), 318 deletions(-) (limited to 'cmd/commands.go') diff --git a/cmd/commands.go b/cmd/commands.go index 2526e45..8a9d482 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -15,343 +15,204 @@ package caddycmd import ( - "bytes" - "crypto/rand" - "encoding/json" "flag" - "fmt" - "io" - "io/ioutil" - "log" - "net" - "net/http" - "os" - "os/exec" - - "strings" - - "github.com/caddyserver/caddy/v2" - "github.com/caddyserver/caddy/v2/caddyconfig" - "github.com/keybase/go-ps" - "github.com/mholt/certmagic" + "regexp" ) -func cmdStart() (int, error) { - startCmd := flag.NewFlagSet("start", flag.ExitOnError) - startCmdConfigFlag := startCmd.String("config", "", "Configuration file") - startCmdConfigAdapterFlag := startCmd.String("config-adapter", "", "Name of config adapter to apply") - startCmd.Parse(os.Args[2:]) +// Command represents a subcommand. All fields +// are required to be set except for Flags if +// there are no flags and Usage if there are +// no flags or arguments. +type Command struct { + Name string - // open a listener to which the child process will connect when - // it is ready to confirm that it has successfully started - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("opening listener for success confirmation: %v", err) - } - defer ln.Close() + // Run is a function that executes a subcommand. + // It returns an exit code and any associated error. + // Takes non-flag commandline arguments as args. + // Flag must be parsed before Run is executed. + Func CommandFunc - // craft the command with a pingback address and with a - // pipe for its stdin, so we can tell it our confirmation - // code that we expect so that some random port scan at - // the most unfortunate time won't fool us into thinking - // the child succeeded (i.e. the alternative is to just - // wait for any connection on our listener, but better to - // ensure it's the process we're expecting - we can be - // sure by giving it some random bytes and having it echo - // them back to us) - cmd := exec.Command(os.Args[0], "run", "--pingback", ln.Addr().String()) - if *startCmdConfigFlag != "" { - cmd.Args = append(cmd.Args, "--config", *startCmdConfigFlag) - } - if *startCmdConfigAdapterFlag != "" { - cmd.Args = append(cmd.Args, "--config-adapter", *startCmdConfigAdapterFlag) - } - stdinpipe, err := cmd.StdinPipe() - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("creating stdin pipe: %v", err) - } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + // Usage is the one-line message explaining args, flags. + Usage string - // generate the random bytes we'll send to the child process - expect := make([]byte, 32) - _, err = rand.Read(expect) - if err != nil { - return caddy.ExitCodeFailedStartup, fmt.Errorf("generating random confirmation bytes: %v", err) - } + // Short is the short description for command. + Short string - // begin writing the confirmation bytes to the child's - // stdin; use a goroutine since the child hasn't been - // started yet, and writing sychronously would result - // in a deadlock - go func() { - stdinpipe.Write(expect) - stdinpipe.Close() - }() + // Long is the message for 'caddy help ' + Long string - // start the process - err = cmd.Start() - if err != nil { - return caddy.ExitCodeFailedStartup, fmt.Errorf("starting caddy process: %v", err) - } - - // there are two ways we know we're done: either - // the process will connect to our listener, or - // it will exit with an error - success, exit := make(chan struct{}), make(chan error) - - // in one goroutine, we await the success of the child process - go func() { - for { - conn, err := ln.Accept() - if err != nil { - if !strings.Contains(err.Error(), "use of closed network connection") { - log.Println(err) - } - break - } - err = handlePingbackConn(conn, expect) - if err == nil { - close(success) - break - } - log.Println(err) - } - }() - - // in another goroutine, we await the failure of the child process - go func() { - err := cmd.Wait() // don't send on this line! Wait blocks, but send starts before it unblocks - exit <- err // sending on separate line ensures select won't trigger until after Wait unblocks - }() - - // when one of the goroutines unblocks, we're done and can exit - select { - case <-success: - fmt.Printf("Successfully started Caddy (pid=%d)\n", cmd.Process.Pid) - case err := <-exit: - return caddy.ExitCodeFailedStartup, - fmt.Errorf("caddy process exited with error: %v", err) - } - - return caddy.ExitCodeSuccess, nil + // Flags is flagset for command. + Flags *flag.FlagSet } -func cmdRun() (int, error) { - runCmd := flag.NewFlagSet("run", flag.ExitOnError) - runCmdConfigFlag := runCmd.String("config", "", "Configuration file") - runCmdConfigAdapterFlag := runCmd.String("config-adapter", "", "Name of config adapter to apply") - runCmdPrintEnvFlag := runCmd.Bool("print-env", false, "Print environment") - runCmdPingbackFlag := runCmd.String("pingback", "", "Echo confirmation bytes to this address on success") - runCmd.Parse(os.Args[2:]) - - // if we are supposed to print the environment, do that first - if *runCmdPrintEnvFlag { - exitCode, err := cmdEnviron() - if err != nil { - return exitCode, err - } - } - - // get the config in caddy's native format - config, err := loadConfig(*runCmdConfigFlag, *runCmdConfigAdapterFlag) - if err != nil { - return caddy.ExitCodeFailedStartup, err - } - - // set a fitting User-Agent for ACME requests - goModule := caddy.GoModule() - cleanModVersion := strings.TrimPrefix(goModule.Version, "v") - certmagic.UserAgent = "Caddy/" + cleanModVersion - - // start the admin endpoint along with any initial config - err = caddy.StartAdmin(config) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("starting caddy administration endpoint: %v", err) - } - defer caddy.StopAdmin() - - // if we are to report to another process the successful start - // of the server, do so now by echoing back contents of stdin - if *runCmdPingbackFlag != "" { - confirmationBytes, err := ioutil.ReadAll(os.Stdin) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("reading confirmation bytes from stdin: %v", err) - } - conn, err := net.Dial("tcp", *runCmdPingbackFlag) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("dialing confirmation address: %v", err) - } - defer conn.Close() - _, err = conn.Write(confirmationBytes) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("writing confirmation bytes to %s: %v", *runCmdPingbackFlag, err) - } - } - - select {} +// CommandFunc is a command's function. It runs the +// command and returns the proper exit code along with +// any error that occurred. +type CommandFunc func(Flags) (int, error) + +var commands = map[string]Command{ + "start": { + Name: "start", + Func: cmdStart, + Usage: "[--config ] [--config-adapter ]", + Short: "Starts the Caddy process and returns after server has started.", + Long: ` +Starts the Caddy process, optionally bootstrapped with an initial +config file. Blocks until server is successfully running (or fails to run), +then returns. On Windows, the child process will remain attached to the +terminal, so closing the window will forcefully stop Caddy. See run for more +details.`, + Flags: func() *flag.FlagSet { + fs := flag.NewFlagSet("start", flag.ExitOnError) + fs.String("config", "", "Configuration file") + fs.String("config-adapter", "", "Name of config adapter to apply") + return fs + }(), + }, + + "run": { + Name: "run", + Func: cmdRun, + Usage: "[--config ] [--config-adapter ] [--print-env]", + Short: `Starts the Caddy process and blocks indefinitely.`, + Long: ` +Same as start, but blocks indefinitely; i.e. runs Caddy in "daemon" mode. On +Windows, this is recommended over caddy start when running Caddy manually since +it will be more obvious that Caddy is still running and bound to the terminal +window. + +If a config file is specified, it will be applied immediately after the process +is running. If the config file is not in Caddy's native JSON format, you can +specify an adapter with --config-adapter to adapt the given config file to +Caddy's native format. The config adapter must be a registered module. Any +warnings will be printed to the log, but beware that any adaptation without +errors will immediately be used. If you want to review the results of the +adaptation first, use adapt-config. + +As a special case, if the current working directory has a file called +"Caddyfile" and the caddyfile config adapter is plugged in (default), then that +file will be loaded and used to configure Caddy, even without any command line +flags. + +If --print-env is specified, the environment as seen by the Caddy process will +be printed before starting. This is the same as the environ command but does +not quit after printing.`, + Flags: func() *flag.FlagSet { + fs := flag.NewFlagSet("run", flag.ExitOnError) + fs.String("config", "", "Configuration file") + fs.String("config-adapter", "", "Name of config adapter to apply") + fs.Bool("print-env", false, "Print environment") + fs.String("pingback", "", "Echo confirmation bytes to this address on success") + return fs + }(), + }, + + "stop": { + Name: "stop", + Func: cmdStop, + Short: "Gracefully stops the running Caddy process", + Long: `Gracefully stops the running Caddy process. (Note: this will stop any process +named the same as the executable.) On Windows, this stop is forceful and Caddy +will not have an opportunity to clean up any active locks; for a graceful +shutdown on Windows, use Ctrl+C or the /stop endpoint.`, + }, + + "reload": { + Name: "reload", + Func: cmdReload, + Usage: "--config [--config-adapter ] [--address ]", + Short: "Gives the running Caddy instance a new configuration", + Long: `Gives the running Caddy instance a new configuration. This has the same effect +as POSTing a document to the /load endpoint, but is convenient for simple +workflows revolving around config files. Since the admin endpoint is +configurable, the endpoint configuration is loaded from the --address flag if +specified; otherwise it is loaded from the given config file; otherwise the +default is assumed.`, + Flags: func() *flag.FlagSet { + fs := flag.NewFlagSet("load", flag.ExitOnError) + fs.String("config", "", "Configuration file") + fs.String("config-adapter", "", "Name of config adapter to apply") + fs.String("address", "", "Address of the administration listener, if different from config") + return fs + }(), + }, + + "version": { + Name: "version", + Func: cmdVersion, + Short: "Prints the version.", + Long: `Prints the version.`, + }, + + "list-modules": { + Name: "list-modules", + Func: cmdListModules, + Short: "List installed Caddy modules.", + Long: `List installed Caddy modules.`, + }, + + "environ": { + Name: "environ", + Func: cmdEnviron, + Short: "Prints the environment as seen by Caddy.", + Long: `Prints the environment as seen by Caddy.`, + }, + + "adapt-config": { + Name: "adapt-config", + Func: cmdAdaptConfig, + Usage: "--input --adapter [--pretty]", + Short: "Adapts a configuration to Caddy's native JSON config structure", + Long: ` +Adapts a configuration to Caddy's native JSON config structure and writes the +output to stdout, along with any warnings to stderr. If --pretty is specified, +the output will be formatted with indentation for human readability.`, + }, } -func cmdStop() (int, error) { - processList, err := ps.Processes() - if err != nil { - return caddy.ExitCodeFailedStartup, fmt.Errorf("listing processes: %v", err) - } - thisProcName := getProcessName() - var found bool - for _, p := range processList { - // the process we're looking for should have the same name but different PID - if p.Executable() == thisProcName && p.Pid() != os.Getpid() { - found = true - fmt.Printf("pid=%d\n", p.Pid()) - - if err := gracefullyStopProcess(p.Pid()); err != nil { - return caddy.ExitCodeFailedStartup, err - } - } - } - if !found { - return caddy.ExitCodeFailedStartup, fmt.Errorf("Caddy is not running") +func init() { + // the help command is special in that its func + // refers to the commands map; thus, defining it + // inline with the commands map's initialization + // yields a compile-time error, so we have to + // define this command separately + commands["help"] = Command{ + Name: "help", + Func: cmdHelp, + Usage: "", + Short: "Shows help for a Caddy subcommand.", } - fmt.Println(" success") - return caddy.ExitCodeSuccess, nil } -func cmdReload() (int, error) { - reloadCmd := flag.NewFlagSet("load", flag.ExitOnError) - reloadCmdConfigFlag := reloadCmd.String("config", "", "Configuration file") - reloadCmdConfigAdapterFlag := reloadCmd.String("config-adapter", "", "Name of config adapter to apply") - reloadCmdAddrFlag := reloadCmd.String("address", "", "Address of the administration listener, if different from config") - reloadCmd.Parse(os.Args[2:]) - - // a configuration is required - if *reloadCmdConfigFlag == "" { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("no configuration to load (use --config)") - } - - // get the config in caddy's native format - config, err := loadConfig(*reloadCmdConfigFlag, *reloadCmdConfigAdapterFlag) - if err != nil { - return caddy.ExitCodeFailedStartup, err - } - - // get the address of the admin listener and craft endpoint URL - adminAddr := *reloadCmdAddrFlag - if adminAddr == "" { - var tmpStruct struct { - Admin caddy.AdminConfig `json:"admin"` - } - err = json.Unmarshal(config, &tmpStruct) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("unmarshaling admin listener address from config: %v", err) - } - adminAddr = tmpStruct.Admin.Listen +// RegisterCommand registers the command cmd. +// cmd.Name must be unique and conform to the +// following format: +// +// - lowercase +// - alphanumeric and hyphen characters only +// - cannot start or end with a hyphen +// - hyphen cannot be adjacent to another hyphen +// +// This function panics if the name is already registered, +// if the name does not meet the described format, or if +// any of the fields are missing from cmd. +func RegisterCommand(cmd Command) { + if cmd.Name == "" { + panic("command name is required") } - if adminAddr == "" { - adminAddr = caddy.DefaultAdminListen + if cmd.Func == nil { + panic("command function missing") } - adminEndpoint := fmt.Sprintf("http://%s/load", adminAddr) - - // send the configuration to the instance - resp, err := http.Post(adminEndpoint, "application/json", bytes.NewReader(config)) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("sending configuration to instance: %v", err) + if cmd.Short == "" { + panic("command short string is required") } - defer resp.Body.Close() - - // if it didn't work, let the user know - if resp.StatusCode >= 400 { - respBody, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1024*10)) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("HTTP %d: reading error message: %v", resp.StatusCode, err) - } - return caddy.ExitCodeFailedStartup, - fmt.Errorf("caddy responded with error: HTTP %d: %s", resp.StatusCode, respBody) + if _, exists := commands[cmd.Name]; exists { + panic("command already registered: " + cmd.Name) } - - return caddy.ExitCodeSuccess, nil -} - -func cmdVersion() (int, error) { - goModule := caddy.GoModule() - if goModule.Sum != "" { - // a build with a known version will also have a checksum - fmt.Printf("%s %s\n", goModule.Version, goModule.Sum) - } else { - fmt.Println(goModule.Version) - } - return caddy.ExitCodeSuccess, nil -} - -func cmdListModules() (int, error) { - for _, m := range caddy.Modules() { - fmt.Println(m) - } - return caddy.ExitCodeSuccess, nil -} - -func cmdEnviron() (int, error) { - for _, v := range os.Environ() { - fmt.Println(v) + if !commandNameRegex.MatchString(cmd.Name) { + panic("invalid command name") } - return caddy.ExitCodeSuccess, nil + commands[cmd.Name] = cmd } -func cmdAdaptConfig() (int, error) { - adaptCmd := flag.NewFlagSet("adapt", flag.ExitOnError) - adaptCmdAdapterFlag := adaptCmd.String("adapter", "", "Name of config adapter") - adaptCmdInputFlag := adaptCmd.String("input", "", "Configuration file to adapt") - adaptCmdPrettyFlag := adaptCmd.Bool("pretty", false, "Format the output for human readability") - adaptCmd.Parse(os.Args[2:]) - - if *adaptCmdAdapterFlag == "" || *adaptCmdInputFlag == "" { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("usage: caddy adapt-config --adapter --input ") - } - - cfgAdapter := caddyconfig.GetAdapter(*adaptCmdAdapterFlag) - if cfgAdapter == nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("unrecognized config adapter: %s", *adaptCmdAdapterFlag) - } - - input, err := ioutil.ReadFile(*adaptCmdInputFlag) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("reading input file: %v", err) - } - - opts := make(map[string]interface{}) - if *adaptCmdPrettyFlag { - opts["pretty"] = "true" - } - - adaptedConfig, warnings, err := cfgAdapter.Adapt(input, opts) - if err != nil { - return caddy.ExitCodeFailedStartup, err - } - - // print warnings to stderr - for _, warn := range warnings { - msg := warn.Message - if warn.Directive != "" { - msg = fmt.Sprintf("%s: %s", warn.Directive, warn.Message) - } - log.Printf("[WARNING][%s] %s:%d: %s", *adaptCmdAdapterFlag, warn.File, warn.Line, msg) - } - - // print result to stdout - fmt.Println(string(adaptedConfig)) - - return caddy.ExitCodeSuccess, nil -} +var commandNameRegex = regexp.MustCompile(`^[a-z0-9]$|^([a-z0-9]+-?[a-z0-9]*)+[a-z0-9]$`) -- cgit v1.2.3