From 30b92edc996e468925e7de5a68626fad0b600f5c Mon Sep 17 00:00:00 2001 From: Anbraten <6918444+anbraten@users.noreply.github.com> Date: Mon, 19 Feb 2024 09:16:27 +0100 Subject: [PATCH] Cli updater (#3382) --- cli/common/flags.go | 5 ++ cli/common/hooks.go | 67 +++++++++++++++++++++ cli/update/command.go | 72 ++++++++++++++++++++++ cli/update/tar.go | 60 +++++++++++++++++++ cli/update/types.go | 16 +++++ cli/update/updater.go | 135 ++++++++++++++++++++++++++++++++++++++++++ cmd/cli/app.go | 8 ++- 7 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 cli/common/hooks.go create mode 100644 cli/update/command.go create mode 100644 cli/update/tar.go create mode 100644 cli/update/types.go create mode 100644 cli/update/updater.go diff --git a/cli/common/flags.go b/cli/common/flags.go index 38d4817a3..39640aad6 100644 --- a/cli/common/flags.go +++ b/cli/common/flags.go @@ -33,6 +33,11 @@ var GlobalFlags = append([]cli.Flag{ Aliases: []string{"s"}, Usage: "server address", }, + &cli.BoolFlag{ + EnvVars: []string{"DISABLE_UPDATE_CHECK"}, + Name: "disable-update-check", + Usage: "disable update check", + }, &cli.BoolFlag{ EnvVars: []string{"WOODPECKER_SKIP_VERIFY"}, Name: "skip-verify", diff --git a/cli/common/hooks.go b/cli/common/hooks.go new file mode 100644 index 000000000..82a8f9d20 --- /dev/null +++ b/cli/common/hooks.go @@ -0,0 +1,67 @@ +package common + +import ( + "context" + "errors" + "time" + + "github.com/rs/zerolog/log" + "github.com/urfave/cli/v2" + + "go.woodpecker-ci.org/woodpecker/v2/cli/update" +) + +var ( + waitForUpdateCheck context.Context + cancelWaitForUpdate context.CancelCauseFunc +) + +func Before(c *cli.Context) error { + if err := SetupGlobalLogger(c); err != nil { + return err + } + + go func() { + if c.Bool("disable-update-check") { + return + } + + // Don't check for updates when the update command is executed + if firstArg := c.Args().First(); firstArg == "update" { + return + } + + waitForUpdateCheck, cancelWaitForUpdate = context.WithCancelCause(context.Background()) + defer cancelWaitForUpdate(errors.New("update check finished")) + + log.Debug().Msg("Checking for updates ...") + + newVersion, err := update.CheckForUpdate(waitForUpdateCheck, true) + if err != nil { + log.Error().Err(err).Msgf("Failed to check for updates") + return + } + + if newVersion != nil { + log.Warn().Msgf("A new version of woodpecker-cli is available: %s. Update by running: %s update", newVersion.Version, c.App.Name) + } else { + log.Debug().Msgf("No update required") + } + }() + + return nil +} + +func After(_ *cli.Context) error { + if waitForUpdateCheck != nil { + select { + case <-waitForUpdateCheck.Done(): + // When the actual command already finished, we still wait 250ms for the update check to finish + case <-time.After(time.Millisecond * 250): + log.Debug().Msg("Update check stopped due to timeout") + cancelWaitForUpdate(errors.New("update check timeout")) + } + } + + return nil +} diff --git a/cli/update/command.go b/cli/update/command.go new file mode 100644 index 000000000..786a5c62b --- /dev/null +++ b/cli/update/command.go @@ -0,0 +1,72 @@ +package update + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/rs/zerolog/log" + "github.com/urfave/cli/v2" +) + +// Command exports the update command. +var Command = &cli.Command{ + Name: "update", + Usage: "update the woodpecker-cli to the latest version", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "force", + Usage: "force update even if the latest version is already installed", + }, + }, + Action: update, +} + +func update(c *cli.Context) error { + log.Info().Msg("Checking for updates ...") + + newVersion, err := CheckForUpdate(c.Context, c.Bool("force")) + if err != nil { + return err + } + + if newVersion == nil { + fmt.Println("You are using the latest version of woodpecker-cli") + return nil + } + + log.Info().Msgf("New version %s is available! Updating ...", newVersion.Version) + + var tarFilePath string + tarFilePath, err = downloadNewVersion(c.Context, newVersion.AssetURL) + if err != nil { + return err + } + + log.Debug().Msgf("New version %s has been downloaded successfully! Installing ...", newVersion.Version) + + binFile, err := extractNewVersion(tarFilePath) + if err != nil { + return err + } + + log.Debug().Msgf("New version %s has been extracted to %s", newVersion.Version, binFile) + + executablePathOrSymlink, err := os.Executable() + if err != nil { + return err + } + + executablePath, err := filepath.EvalSymlinks(executablePathOrSymlink) + if err != nil { + return err + } + + if err := os.Rename(binFile, executablePath); err != nil { + return err + } + + log.Info().Msgf("woodpecker-cli has been updated to version %s successfully!", newVersion.Version) + + return nil +} diff --git a/cli/update/tar.go b/cli/update/tar.go new file mode 100644 index 000000000..16fe2d0a2 --- /dev/null +++ b/cli/update/tar.go @@ -0,0 +1,60 @@ +package update + +import ( + "archive/tar" + "compress/gzip" + "io" + "io/fs" + "os" + "path/filepath" +) + +const tarDirectoryMode fs.FileMode = 0x755 + +func Untar(dst string, r io.Reader) error { + gzr, err := gzip.NewReader(r) + if err != nil { + return err + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + + for { + header, err := tr.Next() + + switch { + case err == io.EOF: + return nil + + case err != nil: + return err + + case header == nil: + continue + } + + target := filepath.Join(dst, header.Name) + + switch header.Typeflag { + case tar.TypeDir: + if _, err := os.Stat(target); err != nil { + if err := os.MkdirAll(target, tarDirectoryMode); err != nil { + return err + } + } + + case tar.TypeReg: + f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) + if err != nil { + return err + } + + if _, err := io.Copy(f, tr); err != nil { + return err + } + + f.Close() + } + } +} diff --git a/cli/update/types.go b/cli/update/types.go new file mode 100644 index 000000000..35ae99181 --- /dev/null +++ b/cli/update/types.go @@ -0,0 +1,16 @@ +package update + +type GithubRelease struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` +} + +type NewVersion struct { + Version string + AssetURL string +} + +const githubReleaseURL = "https://api.github.com/repos/woodpecker-ci/woodpecker/releases/latest" diff --git a/cli/update/updater.go b/cli/update/updater.go new file mode 100644 index 000000000..dce5b5d1c --- /dev/null +++ b/cli/update/updater.go @@ -0,0 +1,135 @@ +package update + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path" + "runtime" + + "github.com/rs/zerolog/log" + + "go.woodpecker-ci.org/woodpecker/v2/version" +) + +func CheckForUpdate(ctx context.Context, force bool) (*NewVersion, error) { + log.Debug().Msgf("Current version: %s", version.String()) + + if version.String() == "dev" && !force { + log.Debug().Msgf("Skipping update check for development version") + return nil, nil + } + + req, err := http.NewRequestWithContext(ctx, "GET", githubReleaseURL, nil) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.New("failed to fetch the latest release") + } + + var release GithubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, err + } + + // using the latest release + if release.TagName == version.String() && !force { + return nil, nil + } + + log.Debug().Msgf("Latest version: %s", release.TagName) + + assetURL := "" + fileName := fmt.Sprintf("woodpecker-cli_%s_%s.tar.gz", runtime.GOOS, runtime.GOARCH) + for _, asset := range release.Assets { + if fileName == asset.Name { + assetURL = asset.BrowserDownloadURL + log.Debug().Msgf("Found asset for the current OS and arch: %s", assetURL) + break + } + } + + if assetURL == "" { + return nil, errors.New("no asset found for the current OS") + } + + return &NewVersion{ + Version: release.TagName, + AssetURL: assetURL, + }, nil +} + +func downloadNewVersion(ctx context.Context, downloadURL string) (string, error) { + log.Debug().Msgf("Downloading new version from %s ...", downloadURL) + + req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) + if err != nil { + return "", err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", errors.New("failed to download the new version") + } + + file, err := os.CreateTemp("", "woodpecker-cli-*.tar.gz") + if err != nil { + return "", err + } + defer file.Close() + + if _, err := io.Copy(file, resp.Body); err != nil { + return "", err + } + + log.Debug().Msgf("New version downloaded to %s", file.Name()) + + return file.Name(), nil +} + +func extractNewVersion(tarFilePath string) (string, error) { + log.Debug().Msgf("Extracting new version from %s ...", tarFilePath) + + tarFile, err := os.Open(tarFilePath) + if err != nil { + return "", err + } + + defer tarFile.Close() + + tmpDir, err := os.MkdirTemp("", "woodpecker-cli-*") + if err != nil { + return "", err + } + + err = Untar(tmpDir, tarFile) + if err != nil { + return "", err + } + + err = os.Remove(tarFilePath) + if err != nil { + return "", err + } + + log.Debug().Msgf("New version extracted to %s", tmpDir) + + return path.Join(tmpDir, "woodpecker-cli"), nil +} diff --git a/cmd/cli/app.go b/cmd/cli/app.go index 720529011..92ba17237 100644 --- a/cmd/cli/app.go +++ b/cmd/cli/app.go @@ -29,6 +29,7 @@ import ( "go.woodpecker-ci.org/woodpecker/v2/cli/registry" "go.woodpecker-ci.org/woodpecker/v2/cli/repo" "go.woodpecker-ci.org/woodpecker/v2/cli/secret" + "go.woodpecker-ci.org/woodpecker/v2/cli/update" "go.woodpecker-ci.org/woodpecker/v2/cli/user" "go.woodpecker-ci.org/woodpecker/v2/version" ) @@ -37,11 +38,13 @@ import ( func newApp() *cli.App { app := cli.NewApp() app.Name = "woodpecker-cli" + app.Description = "Woodpecker command line utility" app.Version = version.String() - app.Usage = "command line utility" app.EnableBashCompletion = true app.Flags = common.GlobalFlags - app.Before = common.SetupGlobalLogger + app.Before = common.Before + app.After = common.After + app.Suggest = true app.Commands = []*cli.Command{ pipeline.Command, log.Command, @@ -55,6 +58,7 @@ func newApp() *cli.App { lint.Command, loglevel.Command, cron.Command, + update.Command, } return app