Skip to content

Commit

Permalink
Cli updater (#3382)
Browse files Browse the repository at this point in the history
  • Loading branch information
anbraten authored Feb 19, 2024
1 parent 99037b2 commit 30b92ed
Show file tree
Hide file tree
Showing 7 changed files with 361 additions and 2 deletions.
5 changes: 5 additions & 0 deletions cli/common/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
67 changes: 67 additions & 0 deletions cli/common/hooks.go
Original file line number Diff line number Diff line change
@@ -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 changes: 72 additions & 0 deletions cli/update/command.go
Original file line number Diff line number Diff line change
@@ -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 changes: 60 additions & 0 deletions cli/update/tar.go
Original file line number Diff line number Diff line change
@@ -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 changes: 16 additions & 0 deletions cli/update/types.go
Original file line number Diff line number Diff line change
@@ -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 changes: 135 additions & 0 deletions cli/update/updater.go
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 6 additions & 2 deletions cmd/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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,
Expand All @@ -55,6 +58,7 @@ func newApp() *cli.App {
lint.Command,
loglevel.Command,
cron.Command,
update.Command,
}

return app
Expand Down

0 comments on commit 30b92ed

Please sign in to comment.