Cli updater (#3382)

This commit is contained in:
Anbraten 2024-02-19 09:16:27 +01:00 committed by GitHub
parent 99037b2d97
commit 30b92edc99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 361 additions and 2 deletions

View file

@ -33,6 +33,11 @@ var GlobalFlags = append([]cli.Flag{
Aliases: []string{"s"}, Aliases: []string{"s"},
Usage: "server address", Usage: "server address",
}, },
&cli.BoolFlag{
EnvVars: []string{"DISABLE_UPDATE_CHECK"},
Name: "disable-update-check",
Usage: "disable update check",
},
&cli.BoolFlag{ &cli.BoolFlag{
EnvVars: []string{"WOODPECKER_SKIP_VERIFY"}, EnvVars: []string{"WOODPECKER_SKIP_VERIFY"},
Name: "skip-verify", Name: "skip-verify",

67
cli/common/hooks.go Normal file
View file

@ -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
}

72
cli/update/command.go Normal file
View file

@ -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
}

60
cli/update/tar.go Normal file
View file

@ -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()
}
}
}

16
cli/update/types.go Normal file
View file

@ -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"

135
cli/update/updater.go Normal file
View file

@ -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
}

View file

@ -29,6 +29,7 @@ import (
"go.woodpecker-ci.org/woodpecker/v2/cli/registry" "go.woodpecker-ci.org/woodpecker/v2/cli/registry"
"go.woodpecker-ci.org/woodpecker/v2/cli/repo" "go.woodpecker-ci.org/woodpecker/v2/cli/repo"
"go.woodpecker-ci.org/woodpecker/v2/cli/secret" "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/cli/user"
"go.woodpecker-ci.org/woodpecker/v2/version" "go.woodpecker-ci.org/woodpecker/v2/version"
) )
@ -37,11 +38,13 @@ import (
func newApp() *cli.App { func newApp() *cli.App {
app := cli.NewApp() app := cli.NewApp()
app.Name = "woodpecker-cli" app.Name = "woodpecker-cli"
app.Description = "Woodpecker command line utility"
app.Version = version.String() app.Version = version.String()
app.Usage = "command line utility"
app.EnableBashCompletion = true app.EnableBashCompletion = true
app.Flags = common.GlobalFlags app.Flags = common.GlobalFlags
app.Before = common.SetupGlobalLogger app.Before = common.Before
app.After = common.After
app.Suggest = true
app.Commands = []*cli.Command{ app.Commands = []*cli.Command{
pipeline.Command, pipeline.Command,
log.Command, log.Command,
@ -55,6 +58,7 @@ func newApp() *cli.App {
lint.Command, lint.Command,
loglevel.Command, loglevel.Command,
cron.Command, cron.Command,
update.Command,
} }
return app return app