1
Fork 0
mirror of https://github.com/caddyserver/caddy.git synced 2024-12-16 21:56:40 -05:00
caddy/cmd/packagesfuncs.go
Lucas VerÍssimo Botelho d398898b35
cmd: Allow add-package to select version of package (#6665)
* feat: allow versioning of packages

* docs: remove xcaddy issue reference
2024-10-30 16:48:36 +00:00

354 lines
11 KiB
Go

// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package caddycmd
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"runtime/debug"
"strings"
"go.uber.org/zap"
"github.com/caddyserver/caddy/v2"
)
func cmdUpgrade(fl Flags) (int, error) {
_, nonstandard, _, err := getModules()
if err != nil {
return caddy.ExitCodeFailedStartup, fmt.Errorf("unable to enumerate installed plugins: %v", err)
}
pluginPkgs, err := getPluginPackages(nonstandard)
if err != nil {
return caddy.ExitCodeFailedStartup, err
}
return upgradeBuild(pluginPkgs, fl)
}
func splitModule(arg string) (module, version string, err error) {
const versionSplit = "@"
// accommodate module paths that have @ in them, but we can only tolerate that if there's also
// a version, otherwise we don't know if it's a version separator or part of the file path
lastVersionSplit := strings.LastIndex(arg, versionSplit)
if lastVersionSplit < 0 {
module = arg
} else {
module, version = arg[:lastVersionSplit], arg[lastVersionSplit+1:]
}
if module == "" {
err = fmt.Errorf("module name is required")
}
return
}
func cmdAddPackage(fl Flags) (int, error) {
if len(fl.Args()) == 0 {
return caddy.ExitCodeFailedStartup, fmt.Errorf("at least one package name must be specified")
}
_, nonstandard, _, err := getModules()
if err != nil {
return caddy.ExitCodeFailedStartup, fmt.Errorf("unable to enumerate installed plugins: %v", err)
}
pluginPkgs, err := getPluginPackages(nonstandard)
if err != nil {
return caddy.ExitCodeFailedStartup, err
}
for _, arg := range fl.Args() {
module, version, err := splitModule(arg)
if err != nil {
return caddy.ExitCodeFailedStartup, fmt.Errorf("invalid module name: %v", err)
}
// only allow a version to be specified if it's different from the existing version
if _, ok := pluginPkgs[module]; ok && !(version != "" && pluginPkgs[module].Version != version) {
return caddy.ExitCodeFailedStartup, fmt.Errorf("package is already added")
}
pluginPkgs[module] = pluginPackage{Version: version, Path: module}
}
return upgradeBuild(pluginPkgs, fl)
}
func cmdRemovePackage(fl Flags) (int, error) {
if len(fl.Args()) == 0 {
return caddy.ExitCodeFailedStartup, fmt.Errorf("at least one package name must be specified")
}
_, nonstandard, _, err := getModules()
if err != nil {
return caddy.ExitCodeFailedStartup, fmt.Errorf("unable to enumerate installed plugins: %v", err)
}
pluginPkgs, err := getPluginPackages(nonstandard)
if err != nil {
return caddy.ExitCodeFailedStartup, err
}
for _, arg := range fl.Args() {
module, _, err := splitModule(arg)
if err != nil {
return caddy.ExitCodeFailedStartup, fmt.Errorf("invalid module name: %v", err)
}
if _, ok := pluginPkgs[module]; !ok {
// package does not exist
return caddy.ExitCodeFailedStartup, fmt.Errorf("package is not added")
}
delete(pluginPkgs, arg)
}
return upgradeBuild(pluginPkgs, fl)
}
func upgradeBuild(pluginPkgs map[string]pluginPackage, fl Flags) (int, error) {
l := caddy.Log()
thisExecPath, err := os.Executable()
if err != nil {
return caddy.ExitCodeFailedStartup, fmt.Errorf("determining current executable path: %v", err)
}
thisExecStat, err := os.Stat(thisExecPath)
if err != nil {
return caddy.ExitCodeFailedStartup, fmt.Errorf("retrieving current executable permission bits: %v", err)
}
if thisExecStat.Mode()&os.ModeSymlink == os.ModeSymlink {
symSource := thisExecPath
// we are a symlink; resolve it
thisExecPath, err = filepath.EvalSymlinks(thisExecPath)
if err != nil {
return caddy.ExitCodeFailedStartup, fmt.Errorf("resolving current executable symlink: %v", err)
}
l.Info("this executable is a symlink", zap.String("source", symSource), zap.String("target", thisExecPath))
}
l.Info("this executable will be replaced", zap.String("path", thisExecPath))
// build the request URL to download this custom build
qs := url.Values{
"os": {runtime.GOOS},
"arch": {runtime.GOARCH},
}
for _, pkgInfo := range pluginPkgs {
qs.Add("p", pkgInfo.String())
}
// initiate the build
resp, err := downloadBuild(qs)
if err != nil {
return caddy.ExitCodeFailedStartup, fmt.Errorf("download failed: %v", err)
}
defer resp.Body.Close()
// 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
err = writeCaddyBinary(thisExecPath, &resp.Body, thisExecStat)
if err != nil {
return caddy.ExitCodeFailedStartup, err
}
l.Info("download successful; displaying new binary details", zap.String("location", thisExecPath))
// use the new binary to print out version and module info
fmt.Print("\nModule versions:\n\n")
if err = listModules(thisExecPath); err != nil {
return caddy.ExitCodeFailedStartup, fmt.Errorf("download succeeded, but unable to execute 'caddy list-modules': %v", err)
}
fmt.Println("\nVersion:")
if err = showVersion(thisExecPath); err != nil {
return caddy.ExitCodeFailedStartup, fmt.Errorf("download succeeded, but unable to execute 'caddy version': %v", err)
}
fmt.Println()
// clean up the backup file
if !fl.Bool("keep-backup") {
if err = removeCaddyBinary(backupExecPath); err != nil {
return caddy.ExitCodeFailedStartup, fmt.Errorf("download succeeded, but unable to clean up backup binary: %v", err)
}
} else {
l.Info("skipped cleaning up the backup file", zap.String("backup_path", backupExecPath))
}
l.Info("upgrade successful; please restart any running Caddy instances", zap.String("executable", thisExecPath))
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 := any(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
}
func listModules(path string) error {
cmd := exec.Command(path, "list-modules", "--versions", "--skip-standard")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func showVersion(path string) error {
cmd := exec.Command(path, "version")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func downloadBuild(qs url.Values) (*http.Response, error) {
l := caddy.Log()
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(fmt.Sprintf("%s?%s", downloadPath, qs.Encode()))
if err != nil {
return nil, fmt.Errorf("secure request failed: %v", err)
}
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 nil, fmt.Errorf("download and error decoding failed: HTTP %d: %v", resp.StatusCode, err2)
}
return nil, fmt.Errorf("download failed: HTTP %d: %s (id=%s)", resp.StatusCode, details.Error.Message, details.Error.ID)
}
return resp, nil
}
func getPluginPackages(modules []moduleInfo) (map[string]pluginPackage, error) {
pluginPkgs := make(map[string]pluginPackage)
for _, mod := range modules {
if mod.goModule.Replace != nil {
return nil, fmt.Errorf("cannot auto-upgrade when Go module has been replaced: %s => %s",
mod.goModule.Path, mod.goModule.Replace.Path)
}
pluginPkgs[mod.goModule.Path] = pluginPackage{Version: mod.goModule.Version, Path: mod.goModule.Path}
}
return pluginPkgs, nil
}
func writeCaddyBinary(path string, body *io.ReadCloser, fileInfo os.FileInfo) error {
l := caddy.Log()
destFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileInfo.Mode())
if err != nil {
return fmt.Errorf("unable to open destination file: %v", err)
}
defer destFile.Close()
l.Info("downloading binary", zap.String("destination", path))
_, err = io.Copy(destFile, *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
}
const downloadPath = "https://caddyserver.com/api/download"
type pluginPackage struct {
Version string
Path string
}
func (p pluginPackage) String() string {
if p.Version == "" {
return p.Path
}
return p.Path + "@" + p.Version
}