From 3366384d9347447632ac334ffbbe35fb18738b90 Mon Sep 17 00:00:00 2001 From: Matt Holt Date: Tue, 19 Jan 2021 18:45:49 -0700 Subject: [PATCH] caddycmd: Add upgrade command (#3972) Replaces the current Caddy executable with a new one from the build server. Honors custom builds, as long as plugins are registered on the Caddy website. Requires permissions to replace current executable, of course. This is an experimental command that may get changed or removed later. --- cmd/commandfuncs.go | 247 +++++++++++++++++++++++++++++++++++--------- cmd/commands.go | 9 ++ cmd/notify.go | 2 + 3 files changed, 208 insertions(+), 50 deletions(-) diff --git a/cmd/commandfuncs.go b/cmd/commandfuncs.go index 3bf4b8df318..9640f4bc3d7 100644 --- a/cmd/commandfuncs.go +++ b/cmd/commandfuncs.go @@ -25,6 +25,7 @@ import ( "log" "net" "net/http" + "net/url" "os" "os/exec" "reflect" @@ -364,11 +365,6 @@ func cmdListModules(fl Flags) (int, error) { packages := fl.Bool("packages") versions := fl.Bool("versions") - type moduleInfo struct { - caddyModuleID string - goModule *debug.Module - err error - } printModuleInfo := func(mi moduleInfo) { fmt.Print(mi.caddyModuleID) if versions && mi.goModule != nil { @@ -387,10 +383,8 @@ func cmdListModules(fl Flags) (int, error) { } // organize modules by whether they come with the standard distribution - var standard, nonstandard, unknown []moduleInfo - - bi, ok := debug.ReadBuildInfo() - if !ok { + standard, nonstandard, unknown, err := getModules() + if err != nil { // oh well, just print the module IDs and exit for _, m := range caddy.Modules() { fmt.Println(m) @@ -398,47 +392,6 @@ func cmdListModules(fl Flags) (int, error) { return caddy.ExitCodeSuccess, nil } - for _, modID := range caddy.Modules() { - modInfo, err := caddy.GetModule(modID) - if err != nil { - // that's weird, shouldn't happen - unknown = append(unknown, moduleInfo{caddyModuleID: modID, err: err}) - continue - } - - // to get the Caddy plugin's version info, we need to know - // the package that the Caddy module's value comes from; we - // can use reflection but we need a non-pointer value (I'm - // not sure why), and since New() should return a pointer - // value, we need to dereference it first - iface := interface{}(modInfo.New()) - if rv := reflect.ValueOf(iface); rv.Kind() == reflect.Ptr { - iface = reflect.New(reflect.TypeOf(iface).Elem()).Elem().Interface() - } - modPkgPath := reflect.TypeOf(iface).PkgPath() - - // now we find the Go module that the Caddy module's package - // belongs to; we assume the Caddy module package path will - // be prefixed by its Go module path, and we will choose the - // longest matching prefix in case there are nested modules - var matched *debug.Module - for _, dep := range bi.Deps { - if strings.HasPrefix(modPkgPath, dep.Path) { - if matched == nil || len(dep.Path) > len(matched.Path) { - matched = dep - } - } - } - - caddyModGoMod := moduleInfo{caddyModuleID: modID, goModule: matched} - - if strings.HasPrefix(modPkgPath, caddy.ImportPath) { - standard = append(standard, caddyModGoMod) - } else { - nonstandard = append(nonstandard, caddyModGoMod) - } - } - if len(standard) > 0 { for _, mod := range standard { printModuleInfo(mod) @@ -619,6 +572,144 @@ func cmdFmt(fl Flags) (int, error) { return caddy.ExitCodeSuccess, nil } +func cmdUpgrade(_ Flags) (int, error) { + l := caddy.Log() + + thisExecPath, err := os.Executable() + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("determining current executable path: %v", err) + } + l.Info("this executable will be replaced", zap.String("path", thisExecPath)) + + // get the list of nonstandard plugins + _, nonstandard, _, err := getModules() + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("unable to enumerate installed plugins: %v", err) + } + pluginPkgs := make(map[string]struct{}) + for _, mod := range nonstandard { + if mod.goModule.Replace != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("cannot auto-upgrade when Go module has been replaced: %s => %s", + mod.goModule.Path, mod.goModule.Replace.Path) + } + l.Info("found non-standard module", + zap.String("id", mod.caddyModuleID), + zap.String("package", mod.goModule.Path)) + pluginPkgs[mod.goModule.Path] = struct{}{} + } + + // build the request URL to download this custom build + qs := url.Values{ + "os": {runtime.GOOS}, + "arch": {runtime.GOARCH}, + } + for pkg := range pluginPkgs { + qs.Add("p", pkg) + } + urlStr := fmt.Sprintf("https://caddyserver.com/api/download?%s", qs.Encode()) + + // initiate the build + l.Info("requesting build", + zap.String("os", qs.Get("os")), + zap.String("arch", qs.Get("arch")), + zap.Strings("packages", qs["p"])) + resp, err := http.Get(urlStr) + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("secure request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + var details struct { + StatusCode int `json:"status_code"` + Error struct { + Message string `json:"message"` + ID string `json:"id"` + } `json:"error"` + } + err2 := json.NewDecoder(resp.Body).Decode(&details) + if err2 != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("download and error decoding failed: HTTP %d: %v", resp.StatusCode, err2) + } + return caddy.ExitCodeFailedStartup, fmt.Errorf("download failed: HTTP %d: %s (id=%s)", resp.StatusCode, details.Error.Message, details.Error.ID) + } + + // back up the current binary, in case something goes wrong we can replace it + backupExecPath := thisExecPath + ".tmp" + l.Info("build acquired; backing up current executable", + zap.String("current_path", thisExecPath), + zap.String("backup_path", backupExecPath)) + err = os.Rename(thisExecPath, backupExecPath) + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("backing up current binary: %v", err) + } + defer func() { + if err != nil { + err2 := os.Rename(backupExecPath, thisExecPath) + if err2 != nil { + l.Error("restoring original executable failed; will need to be restored manually", + zap.String("backup_path", backupExecPath), + zap.String("original_path", thisExecPath), + zap.Error(err2)) + } + } + }() + + // download the file; do this in a closure to close reliably before we execute it + writeFile := func() error { + destFile, err := os.OpenFile(thisExecPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0770) + if err != nil { + return fmt.Errorf("unable to open destination file: %v", err) + } + defer destFile.Close() + + l.Info("downloading binary", zap.String("source", urlStr), zap.String("destination", thisExecPath)) + _, err = io.Copy(destFile, resp.Body) + if err != nil { + return fmt.Errorf("unable to download file: %v", err) + } + + err = destFile.Sync() + if err != nil { + return fmt.Errorf("syncing downloaded file to device: %v", err) + } + + return nil + } + err = writeFile() + if err != nil { + return caddy.ExitCodeFailedStartup, err + } + + // use the new binary to print out version and module info + fmt.Print("\nModule versions:\n\n") + cmd := exec.Command(thisExecPath, "list-modules", "--versions") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("download succeeded, but unable to execute: %v", err) + } + fmt.Println("\nVersion:") + cmd = exec.Command(thisExecPath, "version") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("download succeeded, but unable to execute: %v", err) + } + fmt.Println() + + // clean up the backup file + err = os.Remove(backupExecPath) + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("download succeeded, but unable to clean up backup binary: %v", err) + } + + l.Info("upgrade successful; please restart any running Caddy instances", zap.String("executable", thisExecPath)) + + return caddy.ExitCodeSuccess, nil +} + func cmdHelp(fl Flags) (int, error) { const fullDocs = `Full documentation is available at: https://caddyserver.com/docs/command-line` @@ -683,6 +774,56 @@ commands: return caddy.ExitCodeSuccess, nil } +func getModules() (standard, nonstandard, unknown []moduleInfo, err error) { + bi, ok := debug.ReadBuildInfo() + if !ok { + err = fmt.Errorf("no build info") + return + } + + for _, modID := range caddy.Modules() { + modInfo, err := caddy.GetModule(modID) + if err != nil { + // that's weird, shouldn't happen + unknown = append(unknown, moduleInfo{caddyModuleID: modID, err: err}) + continue + } + + // to get the Caddy plugin's version info, we need to know + // the package that the Caddy module's value comes from; we + // can use reflection but we need a non-pointer value (I'm + // not sure why), and since New() should return a pointer + // value, we need to dereference it first + iface := interface{}(modInfo.New()) + if rv := reflect.ValueOf(iface); rv.Kind() == reflect.Ptr { + iface = reflect.New(reflect.TypeOf(iface).Elem()).Elem().Interface() + } + modPkgPath := reflect.TypeOf(iface).PkgPath() + + // now we find the Go module that the Caddy module's package + // belongs to; we assume the Caddy module package path will + // be prefixed by its Go module path, and we will choose the + // longest matching prefix in case there are nested modules + var matched *debug.Module + for _, dep := range bi.Deps { + if strings.HasPrefix(modPkgPath, dep.Path) { + if matched == nil || len(dep.Path) > len(matched.Path) { + matched = dep + } + } + } + + caddyModGoMod := moduleInfo{caddyModuleID: modID, goModule: matched} + + if strings.HasPrefix(modPkgPath, caddy.ImportPath) { + standard = append(standard, caddyModGoMod) + } else { + nonstandard = append(nonstandard, caddyModGoMod) + } + } + return +} + // apiRequest makes an API request to the endpoint adminAddr with the // given HTTP method and request URI. If body is non-nil, it will be // assumed to be Content-Type application/json. @@ -755,3 +896,9 @@ func apiRequest(adminAddr, method, uri string, body io.Reader) error { return nil } + +type moduleInfo struct { + caddyModuleID string + goModule *debug.Module + err error +} diff --git a/cmd/commands.go b/cmd/commands.go index e4a2b918c78..17651940040 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -277,6 +277,15 @@ is always printed to stdout.`, }(), }) + RegisterCommand(Command{ + Name: "upgrade", + Func: cmdUpgrade, + Short: "Upgrade Caddy (EXPERIMENTAL)", + Long: ` +Downloads an updated Caddy binary with the same modules/plugins at the +latest versions. EXPERIMENTAL: May be changed or removed.`, + }) + } // RegisterCommand registers the command cmd. diff --git a/cmd/notify.go b/cmd/notify.go index 920f2a25b18..21e0e69c6d8 100644 --- a/cmd/notify.go +++ b/cmd/notify.go @@ -14,10 +14,12 @@ package caddycmd +// NotifyReadiness notifies process manager of readiness. func NotifyReadiness() error { return notifyReadiness() } +// NotifyReloading notifies process manager of reloading. func NotifyReloading() error { return notifyReloading() }