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
63 changes: 63 additions & 0 deletions cli/common/before.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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() {
// Don't check for updates when the update command is executed
if firstArg := c.Args().First(); firstArg == "update" {
return
}
anbraten marked this conversation as resolved.
Show resolved Hide resolved

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

import (
"fmt"
"os"

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

executablePath, err := os.Executable()
anbraten marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return err
}

if err := os.Rename(binFile, executablePath); err != nil {
return err
}

log.Info().Msgf("woodpecker-cli %s has been updated successfully! Please restart the CLI.", newVersion.Version)
anbraten marked this conversation as resolved.
Show resolved Hide resolved

return nil
}
57 changes: 57 additions & 0 deletions cli/update/tar.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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 {
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, 0x755); err != nil {
anbraten marked this conversation as resolved.
Show resolved Hide resolved
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"
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