Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cli updater #3382

Merged
merged 17 commits into from
Feb 19, 2024
58 changes: 58 additions & 0 deletions cli/common/before.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package common
xoxys marked this conversation as resolved.
Show resolved Hide resolved

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() {
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
}
70 changes: 70 additions & 0 deletions cli/update/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package update

import (
"fmt"
"os"
"path"

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

pwd, err := os.Getwd()
if err != nil {
return err
}
dst := path.Join(pwd, path.Base(c.App.Name))

log.Debug().Msgf("Moving %s to %s", binFile, dst)

// if err := os.Rename(binFile, dst); err != nil {
// return err
// }
anbraten marked this conversation as resolved.
Show resolved Hide resolved

log.Info().Msgf("woodpecker-cli %s has been installed successfully! Please restart the CLI.", newVersion.Version)

return nil
}
71 changes: 71 additions & 0 deletions cli/update/tar.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package update

import (
"archive/tar"
"compress/gzip"
"io"
"os"
"path/filepath"
)

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 {
// if no more files are found return
case err == io.EOF:
return nil

// return any other error
case err != nil:
return err

// if the header is nil, just skip it (not sure how this happens)
case header == nil:
continue
}

// the target location where the dir/file should be created
target := filepath.Join(dst, header.Name)

// the following switch could also be done using fi.Mode(), not sure if there
// a benefit of using one vs. the other.
// fi := header.FileInfo()

// check the file type
switch header.Typeflag {
// if its a dir and it doesn't exist create it
case tar.TypeDir:
if _, err := os.Stat(target); err != nil {
if err := os.MkdirAll(target, 0x755); err != nil {
anbraten marked this conversation as resolved.
Show resolved Hide resolved
return err
}
}

// if it's a file create it
case tar.TypeReg:
f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
if err != nil {
return err
}

// copy over contents
if _, err := io.Copy(f, tr); err != nil {
return err
}

// manually close here after each file operation; defering would cause each file close
// to wait until all operations have completed.
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"
140 changes: 140 additions & 0 deletions cli/update/updater.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package update

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"regexp"
"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)

fileRegex, err := regexp.Compile(fmt.Sprintf("^woodpecker\\-cli\\_%s_%s\\.tar\\.gz$", runtime.GOOS, runtime.GOARCH))
if err != nil {
return nil, err
}

assetURL := ""
for _, asset := range release.Assets {
if fileRegex.MatchString(asset.Name) {
anbraten marked this conversation as resolved.
Show resolved Hide resolved
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
}
9 changes: 7 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,14 @@ 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.Usage = "woodpecker [global options] command [command options] [arguments...]"
anbraten marked this conversation as resolved.
Show resolved Hide resolved
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 +59,7 @@ func newApp() *cli.App {
lint.Command,
loglevel.Command,
cron.Command,
update.Command,
}

return app
Expand Down