Skip to content

Commit 8753a77

Browse files
committed
Add autoupdate functionality
1 parent 565828b commit 8753a77

File tree

8 files changed

+416
-5
lines changed

8 files changed

+416
-5
lines changed

components/local-app/cmd/root.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/gitpod-io/local-app/pkg/config"
2020
"github.com/gitpod-io/local-app/pkg/constants"
2121
"github.com/gitpod-io/local-app/pkg/prettyprint"
22+
"github.com/gitpod-io/local-app/pkg/selfupdate"
2223
"github.com/gitpod-io/local-app/pkg/telemetry"
2324
"github.com/gookit/color"
2425
"github.com/lmittmann/tint"
@@ -97,9 +98,15 @@ var rootCmd = &cobra.Command{
9798
if gpctx, err := cfg.GetActiveContext(); err == nil && gpctx != nil {
9899
telemetryEnabled = telemetryEnabled && gpctx.Host.String() == "https://gitpod.io"
99100
}
100-
telemetry.Init(telemetryEnabled, cfg.Telemetry.Identity, constants.Version)
101+
telemetry.Init(telemetryEnabled, cfg.Telemetry.Identity, constants.Version.String())
101102
telemetry.RecordCommand(cmd)
102103

104+
waitForUpdate := selfupdate.Autoupdate(cmd.Context(), cfg)
105+
cmd.PostRunE = func(cmd *cobra.Command, args []string) error {
106+
waitForUpdate()
107+
return nil
108+
}
109+
103110
return nil
104111
},
105112
}

components/local-app/cmd/version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ var versionCmd = &cobra.Command{
2222
BuildTime string `print:"built at"`
2323
}
2424
v := Version{
25-
Version: constants.Version,
25+
Version: constants.Version.String(),
2626
GitCommit: constants.GitCommit,
2727
BuildTime: constants.MustParseBuildTime().Format(time.RFC3339),
2828
}

components/local-app/go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,17 @@ require (
5050
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
5151
github.com/go-git/go-billy/v5 v5.5.0 // indirect
5252
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
53+
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
5354
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
5455
github.com/json-iterator/go v1.1.12 // indirect
56+
github.com/kr/binarydist v0.1.0 // indirect
5557
github.com/kr/fs v0.1.0 // indirect
5658
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
5759
github.com/pjbgf/sha1cd v0.3.0 // indirect
5860
github.com/pkg/errors v0.9.1 // indirect
5961
github.com/pkg/sftp v1.13.5 // indirect
6062
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
63+
github.com/sanbornm/go-selfupdate v0.0.0-20230714125711-e1c03e3d6ac7 // indirect
6164
github.com/segmentio/backo-go v1.0.0 // indirect
6265
github.com/sergi/go-diff v1.1.0 // indirect
6366
github.com/skeema/knownhosts v1.2.0 // indirect
@@ -70,6 +73,7 @@ require (
7073
)
7174

7275
require (
76+
github.com/Masterminds/semver/v3 v3.2.1
7377
github.com/cenkalti/backoff/v4 v4.1.3 // indirect
7478
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
7579
github.com/danieljoos/wincred v1.1.0 // indirect
@@ -81,6 +85,7 @@ require (
8185
github.com/inconshreveable/mousetrap v1.1.0 // indirect
8286
github.com/klauspost/compress v1.17.0 // indirect
8387
github.com/melbahja/goph v1.4.0
88+
github.com/opencontainers/go-digest v1.0.0
8489
github.com/russross/blackfriday/v2 v2.1.0 // indirect
8590
github.com/segmentio/analytics-go/v3 v3.3.0
8691
github.com/sourcegraph/jsonrpc2 v0.0.0-20200429184054-15c2290dcb37 // indirect

components/local-app/go.sum

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/local-app/main/gitpod-local-companion/main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func main() {
4040
Usage: "connect your Gitpod workspaces",
4141
Action: DefaultCommand("run"),
4242
EnableBashCompletion: true,
43-
Version: constants.Version,
43+
Version: constants.Version.String(),
4444
Flags: []cli.Flag{
4545
&cli.StringFlag{
4646
Name: "gitpod-host",
@@ -283,7 +283,7 @@ func tryConnectToServer(gitpodUrl string, tkn string, reconnectionHandler func()
283283
CloseHandler: closeHandler,
284284
ExtraHeaders: map[string]string{
285285
"User-Agent": "gitpod/local-companion",
286-
"X-Client-Version": constants.Version,
286+
"X-Client-Version": constants.Version.String(),
287287
},
288288
})
289289
if err != nil {

components/local-app/pkg/constants/constants.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ import (
1111
"strings"
1212
"time"
1313

14+
"github.com/Masterminds/semver/v3"
1415
version "github.com/gitpod-io/local-app"
1516
)
1617

1718
var (
1819
// Version is fed from the main CLI version
19-
Version = strings.TrimSpace(version.Version)
20+
Version = semver.MustParse(strings.TrimSpace(version.Version))
2021

2122
// GitCommit - set during build
2223
GitCommit = "unknown"
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
// Copyright (c) 2023 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License.AGPL.txt in the project root for license information.
4+
5+
package selfupdate
6+
7+
import (
8+
"context"
9+
"crypto"
10+
"encoding/hex"
11+
"encoding/json"
12+
"errors"
13+
"fmt"
14+
"log/slog"
15+
"net/http"
16+
"net/url"
17+
"os"
18+
"path/filepath"
19+
"regexp"
20+
"runtime"
21+
"time"
22+
23+
"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/inconshreveable/go-update"
27+
"github.com/opencontainers/go-digest"
28+
)
29+
30+
// Manifest is the manifest of a selfupdate
31+
type Manifest struct {
32+
Version *semver.Version `json:"version"`
33+
Binaries []Binary `json:"binaries"`
34+
}
35+
36+
// Binary describes a single executable binary
37+
type Binary struct {
38+
// URL is added when the manifest is downloaded.
39+
URL string `json:"-"`
40+
41+
Filename string `json:"filename"`
42+
OS string `json:"os"`
43+
Arch string `json:"arch"`
44+
Digest digest.Digest `json:"digest"`
45+
}
46+
47+
type FilenameParserFunc func(filename string) (os, arch string, ok bool)
48+
49+
var regexDefaultFilenamePattern = regexp.MustCompile(`^.*-(linux|darwin|windows)-(amd64|arm64)(\.exe)?$`)
50+
51+
func DefaultFilenameParser(filename string) (os, arch string, ok bool) {
52+
matches := regexDefaultFilenamePattern.FindStringSubmatch(filename)
53+
if matches == nil {
54+
return "", "", false
55+
}
56+
57+
return matches[1], matches[2], true
58+
}
59+
60+
// GenerateManifest generates a manifest for the given location
61+
// by scanning the location for binaries following the naming convention
62+
func GenerateManifest(version *semver.Version, loc string, filenameParser FilenameParserFunc) (*Manifest, error) {
63+
files, err := os.ReadDir(loc)
64+
if err != nil {
65+
return nil, err
66+
}
67+
68+
var binaries []Binary
69+
for _, f := range files {
70+
goos, arch, ok := filenameParser(f.Name())
71+
if !ok {
72+
continue
73+
}
74+
75+
fd, err := os.Open(filepath.Join(loc, f.Name()))
76+
if err != nil {
77+
return nil, err
78+
}
79+
dgst, err := digest.FromReader(fd)
80+
fd.Close()
81+
if err != nil {
82+
return nil, err
83+
}
84+
85+
binaries = append(binaries, Binary{
86+
Filename: f.Name(),
87+
OS: goos,
88+
Arch: arch,
89+
Digest: dgst,
90+
})
91+
}
92+
93+
return &Manifest{
94+
Version: version,
95+
Binaries: binaries,
96+
}, nil
97+
}
98+
99+
// DownloadManifest downloads a manifest from the given URL.
100+
// Expects the manifest to be at <baseURL>/manifest.json.
101+
func DownloadManifest(ctx context.Context, baseURL string) (res *Manifest, err error) {
102+
defer func() {
103+
if err != nil {
104+
err = fmt.Errorf("download manifest from %s: %w", baseURL, err)
105+
}
106+
}()
107+
108+
murl, err := url.Parse(baseURL)
109+
if err != nil {
110+
return nil, err
111+
}
112+
originalPath := murl.Path
113+
murl.Path = filepath.Join(murl.Path, "manifest.json")
114+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, murl.String(), nil)
115+
if err != nil {
116+
return nil, err
117+
}
118+
resp, err := http.DefaultClient.Do(req)
119+
if err != nil {
120+
return nil, err
121+
}
122+
defer resp.Body.Close()
123+
124+
var mf Manifest
125+
err = json.NewDecoder(resp.Body).Decode(&mf)
126+
if err != nil {
127+
return nil, err
128+
}
129+
for i := range mf.Binaries {
130+
murl.Path = filepath.Join(originalPath, mf.Binaries[i].Filename)
131+
mf.Binaries[i].URL = murl.String()
132+
}
133+
134+
return &mf, nil
135+
}
136+
137+
// NeedsUpdate checks if the current version is outdated
138+
func NeedsUpdate(current *semver.Version, manifest *Manifest) bool {
139+
return manifest.Version.GreaterThan(current)
140+
}
141+
142+
// ReplaceSelf replaces the current binary with the one from the manifest, no matter the version
143+
// If there is no matching binary in the manifest, this function returns ErrNoBinaryAvailable.
144+
func ReplaceSelf(ctx context.Context, manifest *Manifest) error {
145+
var binary *Binary
146+
for _, b := range manifest.Binaries {
147+
if b.OS != runtime.GOOS || b.Arch != runtime.GOARCH {
148+
continue
149+
}
150+
151+
binary = &b
152+
break
153+
}
154+
if binary == nil {
155+
return ErrNoBinaryAvailable
156+
}
157+
158+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, binary.URL, nil)
159+
if err != nil {
160+
return err
161+
}
162+
resp, err := http.DefaultClient.Do(req)
163+
if err != nil {
164+
return err
165+
}
166+
defer resp.Body.Close()
167+
168+
dgst, _ := hex.DecodeString(binary.Digest.Hex())
169+
return update.Apply(resp.Body, update.Options{
170+
Checksum: dgst,
171+
Hash: crypto.SHA256,
172+
TargetMode: 0755,
173+
})
174+
}
175+
176+
var ErrNoBinaryAvailable = errors.New("no binary available for this platform")
177+
178+
// Autoupdate checks if there is a newer version available and updates the binary if so
179+
// actually updates. This function returns immediately and runs the update in the background.
180+
// The returned function can be used to wait for the update to finish.
181+
func Autoupdate(ctx context.Context, cfg *config.Config) func() {
182+
if !cfg.Autoupdate {
183+
return func() {}
184+
}
185+
186+
done := make(chan struct{})
187+
go func() {
188+
defer close(done)
189+
190+
gpctx, _ := cfg.GetActiveContext()
191+
if gpctx == nil {
192+
slog.Debug("no active context - autoupdate disabled")
193+
return
194+
}
195+
196+
var err error
197+
defer func() {
198+
if err != nil {
199+
slog.Debug("autoupdate failed", "err", err)
200+
}
201+
}()
202+
203+
mfctx, cancel := context.WithTimeout(ctx, 1*time.Second)
204+
defer cancel()
205+
baseURL := *gpctx.Host.URL
206+
baseURL.Path = "/static/bin"
207+
mf, err := DownloadManifest(mfctx, baseURL.String())
208+
if err != nil {
209+
return
210+
}
211+
212+
if !NeedsUpdate(constants.Version, mf) {
213+
slog.Debug("no update available", "current", constants.Version, "latest", mf.Version)
214+
return
215+
}
216+
217+
dlctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
218+
defer cancel()
219+
slog.Debug("attempting to autoupdate", "current", constants.Version, "latest", mf.Version)
220+
err = ReplaceSelf(dlctx, mf)
221+
}()
222+
223+
return func() {
224+
select {
225+
case <-done:
226+
return
227+
case <-time.After(5 * time.Second):
228+
slog.Warn("autoupdate is still running - press Ctrl+C to abort")
229+
}
230+
}
231+
}

0 commit comments

Comments
 (0)