Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/local-app/pkg/selfupdate/selfupdate.go
2500 views
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
"strings"
22
"time"
23
24
"github.com/Masterminds/semver/v3"
25
"github.com/gitpod-io/local-app/pkg/config"
26
"github.com/gitpod-io/local-app/pkg/constants"
27
"github.com/gitpod-io/local-app/pkg/prettyprint"
28
"github.com/inconshreveable/go-update"
29
"github.com/opencontainers/go-digest"
30
)
31
32
const (
33
// GitpodCLIBasePath is the path relative to a Gitpod installation where the latest
34
// binary and manifest can be found.
35
GitpodCLIBasePath = "/static/bin"
36
)
37
38
// Manifest is the manifest of a selfupdate
39
type Manifest struct {
40
Version *semver.Version `json:"version"`
41
Binaries []Binary `json:"binaries"`
42
}
43
44
// Binary describes a single executable binary
45
type Binary struct {
46
// URL is added when the manifest is downloaded.
47
URL string `json:"-"`
48
49
Filename string `json:"filename"`
50
OS string `json:"os"`
51
Arch string `json:"arch"`
52
Digest digest.Digest `json:"digest"`
53
}
54
55
type FilenameParserFunc func(filename string) (os, arch string, ok bool)
56
57
var regexDefaultFilenamePattern = regexp.MustCompile(`.*-(linux|darwin|windows)-(amd64|arm64)(\.exe)?`)
58
59
func DefaultFilenameParser(filename string) (os, arch string, ok bool) {
60
matches := regexDefaultFilenamePattern.FindStringSubmatch(filename)
61
if matches == nil {
62
return "", "", false
63
}
64
65
return matches[1], matches[2], true
66
}
67
68
// GenerateManifest generates a manifest for the given location
69
// by scanning the location for binaries following the naming convention
70
func GenerateManifest(version *semver.Version, loc string, filenameParser FilenameParserFunc) (*Manifest, error) {
71
files, err := os.ReadDir(loc)
72
if err != nil {
73
return nil, err
74
}
75
76
var binaries []Binary
77
for _, f := range files {
78
goos, arch, ok := filenameParser(f.Name())
79
if !ok {
80
continue
81
}
82
83
fd, err := os.Open(filepath.Join(loc, f.Name()))
84
if err != nil {
85
return nil, err
86
}
87
dgst, err := digest.FromReader(fd)
88
fd.Close()
89
if err != nil {
90
return nil, err
91
}
92
93
binaries = append(binaries, Binary{
94
Filename: f.Name(),
95
OS: goos,
96
Arch: arch,
97
Digest: dgst,
98
})
99
}
100
101
return &Manifest{
102
Version: version,
103
Binaries: binaries,
104
}, nil
105
}
106
107
// DownloadManifest downloads a manifest from the given URL.
108
// Expects the manifest to be at <baseURL>/manifest.json.
109
func DownloadManifest(ctx context.Context, baseURL string) (res *Manifest, err error) {
110
defer func() {
111
if err != nil {
112
pth := strings.TrimSuffix(baseURL, "/") + GitpodCLIBasePath
113
err = prettyprint.AddResolution(fmt.Errorf("cannot download manifest from %s/manifest.json: %w", pth, err),
114
"make sure you are connected to the internet",
115
"make sure you can reach "+baseURL,
116
)
117
}
118
}()
119
120
murl, err := url.Parse(baseURL)
121
if err != nil {
122
return nil, err
123
}
124
murl.Path = filepath.Join(murl.Path, GitpodCLIBasePath)
125
126
originalPath := murl.Path
127
murl.Path = filepath.Join(murl.Path, "manifest.json")
128
req, err := http.NewRequestWithContext(ctx, http.MethodGet, murl.String(), nil)
129
if err != nil {
130
return nil, err
131
}
132
resp, err := http.DefaultClient.Do(req)
133
if err != nil {
134
return nil, err
135
}
136
defer resp.Body.Close()
137
138
if resp.StatusCode != http.StatusOK {
139
return nil, fmt.Errorf("%s", resp.Status)
140
}
141
142
var mf Manifest
143
err = json.NewDecoder(resp.Body).Decode(&mf)
144
if err != nil {
145
return nil, err
146
}
147
for i := range mf.Binaries {
148
murl.Path = filepath.Join(originalPath, mf.Binaries[i].Filename)
149
mf.Binaries[i].URL = murl.String()
150
}
151
152
return &mf, nil
153
}
154
155
// DownloadManifestFromActiveContext downloads the manifest from the active configuration context
156
func DownloadManifestFromActiveContext(ctx context.Context) (res *Manifest, err error) {
157
cfg := config.FromContext(ctx)
158
if cfg == nil {
159
return nil, nil
160
}
161
162
gpctx, _ := cfg.GetActiveContext()
163
if gpctx == nil {
164
slog.Debug("no active context - autoupdate disabled")
165
return
166
}
167
168
mfctx, cancel := context.WithTimeout(ctx, 1*time.Second)
169
defer cancel()
170
mf, err := DownloadManifest(mfctx, gpctx.Host.URL.String())
171
if err != nil {
172
return
173
}
174
175
return mf, nil
176
}
177
178
// NeedsUpdate checks if the current version is outdated
179
func NeedsUpdate(current *semver.Version, manifest *Manifest) bool {
180
return manifest.Version.GreaterThan(current)
181
}
182
183
// ReplaceSelf replaces the current binary with the one from the manifest, no matter the version
184
// If there is no matching binary in the manifest, this function returns ErrNoBinaryAvailable.
185
func ReplaceSelf(ctx context.Context, manifest *Manifest) error {
186
var binary *Binary
187
for _, b := range manifest.Binaries {
188
if b.OS != runtime.GOOS || b.Arch != runtime.GOARCH {
189
continue
190
}
191
192
binary = &b
193
break
194
}
195
if binary == nil {
196
return ErrNoBinaryAvailable
197
}
198
199
req, err := http.NewRequestWithContext(ctx, http.MethodGet, binary.URL, nil)
200
if err != nil {
201
return err
202
}
203
resp, err := http.DefaultClient.Do(req)
204
if err != nil {
205
return err
206
}
207
defer resp.Body.Close()
208
209
dgst, _ := hex.DecodeString(binary.Digest.Hex())
210
err = update.Apply(resp.Body, update.Options{
211
Checksum: dgst,
212
Hash: crypto.SHA256,
213
TargetMode: 0755,
214
})
215
if err != nil && strings.Contains(err.Error(), "permission denied") && runtime.GOOS != "windows" {
216
cfgfn := config.FromContext(ctx).Filename
217
err = prettyprint.AddResolution(err,
218
fmt.Sprintf("run `sudo {gitpod} --config %s version update`", cfgfn),
219
)
220
}
221
return err
222
}
223
224
var ErrNoBinaryAvailable = errors.New("no binary available for this platform")
225
226
// Autoupdate checks if there is a newer version available and updates the binary if so
227
// actually updates. This function returns immediately and runs the update in the background.
228
// The returned function can be used to wait for the update to finish.
229
func Autoupdate(ctx context.Context, cfg *config.Config) func() {
230
if !cfg.Autoupdate {
231
return func() {}
232
}
233
234
done := make(chan struct{})
235
go func() {
236
defer close(done)
237
238
var err error
239
defer func() {
240
if err != nil {
241
slog.Debug("version check failed", "err", err)
242
}
243
}()
244
mf, err := DownloadManifestFromActiveContext(ctx)
245
if err != nil {
246
return
247
}
248
if mf == nil {
249
slog.Debug("no selfupdate version manifest available")
250
return
251
}
252
253
if !NeedsUpdate(constants.Version, mf) {
254
slog.Debug("no update available", "current", constants.Version, "latest", mf.Version)
255
return
256
}
257
258
slog.Warn("new version available - run `"+os.Args[0]+" version update` to update", "current", constants.Version, "latest", mf.Version)
259
}()
260
261
return func() {
262
select {
263
case <-done:
264
return
265
case <-time.After(5 * time.Second):
266
slog.Warn("version check is still running - press Ctrl+C to abort")
267
}
268
}
269
}
270
271