Path: blob/main/components/local-app/pkg/selfupdate/selfupdate.go
2500 views
// Copyright (c) 2023 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License.AGPL.txt in the project root for license information.34package selfupdate56import (7"context"8"crypto"9"encoding/hex"10"encoding/json"11"errors"12"fmt"13"log/slog"14"net/http"15"net/url"16"os"17"path/filepath"18"regexp"19"runtime"20"strings"21"time"2223"github.com/Masterminds/semver/v3"24"github.com/gitpod-io/local-app/pkg/config"25"github.com/gitpod-io/local-app/pkg/constants"26"github.com/gitpod-io/local-app/pkg/prettyprint"27"github.com/inconshreveable/go-update"28"github.com/opencontainers/go-digest"29)3031const (32// GitpodCLIBasePath is the path relative to a Gitpod installation where the latest33// binary and manifest can be found.34GitpodCLIBasePath = "/static/bin"35)3637// Manifest is the manifest of a selfupdate38type Manifest struct {39Version *semver.Version `json:"version"`40Binaries []Binary `json:"binaries"`41}4243// Binary describes a single executable binary44type Binary struct {45// URL is added when the manifest is downloaded.46URL string `json:"-"`4748Filename string `json:"filename"`49OS string `json:"os"`50Arch string `json:"arch"`51Digest digest.Digest `json:"digest"`52}5354type FilenameParserFunc func(filename string) (os, arch string, ok bool)5556var regexDefaultFilenamePattern = regexp.MustCompile(`.*-(linux|darwin|windows)-(amd64|arm64)(\.exe)?`)5758func DefaultFilenameParser(filename string) (os, arch string, ok bool) {59matches := regexDefaultFilenamePattern.FindStringSubmatch(filename)60if matches == nil {61return "", "", false62}6364return matches[1], matches[2], true65}6667// GenerateManifest generates a manifest for the given location68// by scanning the location for binaries following the naming convention69func GenerateManifest(version *semver.Version, loc string, filenameParser FilenameParserFunc) (*Manifest, error) {70files, err := os.ReadDir(loc)71if err != nil {72return nil, err73}7475var binaries []Binary76for _, f := range files {77goos, arch, ok := filenameParser(f.Name())78if !ok {79continue80}8182fd, err := os.Open(filepath.Join(loc, f.Name()))83if err != nil {84return nil, err85}86dgst, err := digest.FromReader(fd)87fd.Close()88if err != nil {89return nil, err90}9192binaries = append(binaries, Binary{93Filename: f.Name(),94OS: goos,95Arch: arch,96Digest: dgst,97})98}99100return &Manifest{101Version: version,102Binaries: binaries,103}, nil104}105106// DownloadManifest downloads a manifest from the given URL.107// Expects the manifest to be at <baseURL>/manifest.json.108func DownloadManifest(ctx context.Context, baseURL string) (res *Manifest, err error) {109defer func() {110if err != nil {111pth := strings.TrimSuffix(baseURL, "/") + GitpodCLIBasePath112err = prettyprint.AddResolution(fmt.Errorf("cannot download manifest from %s/manifest.json: %w", pth, err),113"make sure you are connected to the internet",114"make sure you can reach "+baseURL,115)116}117}()118119murl, err := url.Parse(baseURL)120if err != nil {121return nil, err122}123murl.Path = filepath.Join(murl.Path, GitpodCLIBasePath)124125originalPath := murl.Path126murl.Path = filepath.Join(murl.Path, "manifest.json")127req, err := http.NewRequestWithContext(ctx, http.MethodGet, murl.String(), nil)128if err != nil {129return nil, err130}131resp, err := http.DefaultClient.Do(req)132if err != nil {133return nil, err134}135defer resp.Body.Close()136137if resp.StatusCode != http.StatusOK {138return nil, fmt.Errorf("%s", resp.Status)139}140141var mf Manifest142err = json.NewDecoder(resp.Body).Decode(&mf)143if err != nil {144return nil, err145}146for i := range mf.Binaries {147murl.Path = filepath.Join(originalPath, mf.Binaries[i].Filename)148mf.Binaries[i].URL = murl.String()149}150151return &mf, nil152}153154// DownloadManifestFromActiveContext downloads the manifest from the active configuration context155func DownloadManifestFromActiveContext(ctx context.Context) (res *Manifest, err error) {156cfg := config.FromContext(ctx)157if cfg == nil {158return nil, nil159}160161gpctx, _ := cfg.GetActiveContext()162if gpctx == nil {163slog.Debug("no active context - autoupdate disabled")164return165}166167mfctx, cancel := context.WithTimeout(ctx, 1*time.Second)168defer cancel()169mf, err := DownloadManifest(mfctx, gpctx.Host.URL.String())170if err != nil {171return172}173174return mf, nil175}176177// NeedsUpdate checks if the current version is outdated178func NeedsUpdate(current *semver.Version, manifest *Manifest) bool {179return manifest.Version.GreaterThan(current)180}181182// ReplaceSelf replaces the current binary with the one from the manifest, no matter the version183// If there is no matching binary in the manifest, this function returns ErrNoBinaryAvailable.184func ReplaceSelf(ctx context.Context, manifest *Manifest) error {185var binary *Binary186for _, b := range manifest.Binaries {187if b.OS != runtime.GOOS || b.Arch != runtime.GOARCH {188continue189}190191binary = &b192break193}194if binary == nil {195return ErrNoBinaryAvailable196}197198req, err := http.NewRequestWithContext(ctx, http.MethodGet, binary.URL, nil)199if err != nil {200return err201}202resp, err := http.DefaultClient.Do(req)203if err != nil {204return err205}206defer resp.Body.Close()207208dgst, _ := hex.DecodeString(binary.Digest.Hex())209err = update.Apply(resp.Body, update.Options{210Checksum: dgst,211Hash: crypto.SHA256,212TargetMode: 0755,213})214if err != nil && strings.Contains(err.Error(), "permission denied") && runtime.GOOS != "windows" {215cfgfn := config.FromContext(ctx).Filename216err = prettyprint.AddResolution(err,217fmt.Sprintf("run `sudo {gitpod} --config %s version update`", cfgfn),218)219}220return err221}222223var ErrNoBinaryAvailable = errors.New("no binary available for this platform")224225// Autoupdate checks if there is a newer version available and updates the binary if so226// actually updates. This function returns immediately and runs the update in the background.227// The returned function can be used to wait for the update to finish.228func Autoupdate(ctx context.Context, cfg *config.Config) func() {229if !cfg.Autoupdate {230return func() {}231}232233done := make(chan struct{})234go func() {235defer close(done)236237var err error238defer func() {239if err != nil {240slog.Debug("version check failed", "err", err)241}242}()243mf, err := DownloadManifestFromActiveContext(ctx)244if err != nil {245return246}247if mf == nil {248slog.Debug("no selfupdate version manifest available")249return250}251252if !NeedsUpdate(constants.Version, mf) {253slog.Debug("no update available", "current", constants.Version, "latest", mf.Version)254return255}256257slog.Warn("new version available - run `"+os.Args[0]+" version update` to update", "current", constants.Version, "latest", mf.Version)258}()259260return func() {261select {262case <-done:263return264case <-time.After(5 * time.Second):265slog.Warn("version check is still running - press Ctrl+C to abort")266}267}268}269270271