Skip to content

Commit e4bc514

Browse files
authored
[gitpod-cli] Add auto-updating capabilities (#19056)
* Add version command * Restructure config package * Bring back config get and config set * Support login host without protocol scheme * Add autoupdate functionality * Generate update manifest during build * Better update failure behavior * Add latest to version command * Add version update command * Use cannonical semver form
1 parent 0e00e3d commit e4bc514

File tree

23 files changed

+1070
-109
lines changed

23 files changed

+1070
-109
lines changed

components/local-app/BUILD.js

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const generatePackage = function (goos, goarch, binaryName, mainFile) {
77
let pkg = {
88
name,
99
type: "go",
10-
srcs: ["go.mod", "go.sum", "**/*.go"],
10+
srcs: ["go.mod", "go.sum", "**/*.go", "version.txt"],
1111
deps: [
1212
"components/supervisor-api/go:lib",
1313
"components/gitpod-protocol/go:lib",
@@ -19,14 +19,12 @@ const generatePackage = function (goos, goarch, binaryName, mainFile) {
1919
packaging: "app",
2020
dontTest: dontTest,
2121
buildCommand: [
22-
"go",
23-
"build",
24-
"-trimpath",
25-
"-ldflags",
26-
"-buildid= -w -s -X 'github.com/gitpod-io/local-app/pkg/constants.Version=commit-${__git_commit}'",
27-
"-o",
28-
binaryName,
29-
mainFile,
22+
"sh",
23+
"-c",
24+
'go build -trimpath -ldflags "-X github.com/gitpod-io/local-app/pkg/constants.GitCommit=${__git_commit} -X github.com/gitpod-io/local-app/pkg/constants.BuildTime=$(date +%s)" -o ' +
25+
binaryName +
26+
" " +
27+
mainFile,
3028
],
3129
},
3230
binaryName,

components/local-app/BUILD.yaml

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ packages:
33
- name: docker
44
type: docker
55
deps:
6-
- :app
6+
- :app-with-manifest
77
argdeps:
88
- imageRepoBase
99
config:
@@ -13,7 +13,32 @@ packages:
1313
image:
1414
- ${imageRepoBase}/local-app:${version}
1515
- ${imageRepoBase}/local-app:commit-${__git_commit}
16-
16+
- name: update-manifest
17+
type: go
18+
srcs:
19+
- go.mod
20+
- go.sum
21+
- "**/*.go"
22+
- version.txt
23+
deps:
24+
- components/supervisor-api/go:lib
25+
- components/gitpod-protocol/go:lib
26+
- components/local-app-api/go:lib
27+
- components/public-api/go:lib
28+
config:
29+
packaging: app
30+
dontTest: true
31+
buildCommand: ["go", "build", "-o", "update-manifest", "./main/update-manifest/main.go"]
32+
- name: app-with-manifest
33+
type: generic
34+
deps:
35+
- :app
36+
- :update-manifest
37+
config:
38+
commands:
39+
- ["sh", "-c", "mkdir -p bin && mv components-local-app--app/bin/* bin/"]
40+
- ["sh", "-c", "components-local-app--update-manifest/update-manifest --cwd bin | tee bin/manifest.json"]
41+
- ["rm", "-rf", "components-local-app--update-manifest", "components-local-app--app"]
1742
scripts:
1843
- name: install-cli
1944
description: "Install gitpod-cli as `gitpod` command and add auto-completion. Usage: '. $(leeway run components/local-app:install-cli)'"

components/local-app/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ leeway run components/local-app:install-cli
4141
leeway run components/local-app:cli-completion
4242
```
4343

44+
### Versioning and Release Management
45+
46+
The CLI is versioned independently of other Gitpod artifacts due to its auto-updating behaviour.
47+
To create a new version that existing clients will consume increment the number in `version.txt`. Make sure to use semantic versioning. The minor version can be greater than 10, e.g. `0.342` is a valid version.
48+
4449
## local-app
4550

4651
**Beware**: this is very much work in progress and will likely break things.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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 cmd
6+
7+
import (
8+
"github.com/gitpod-io/local-app/pkg/config"
9+
"github.com/gitpod-io/local-app/pkg/prettyprint"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
var configGetCmd = &cobra.Command{
14+
Use: "get",
15+
Short: "Get an individual config value in the config file",
16+
RunE: func(cmd *cobra.Command, args []string) error {
17+
cmd.SilenceUsage = true
18+
19+
cfg := config.FromContext(cmd.Context())
20+
21+
return WriteTabular([]struct {
22+
Telemetry bool `header:"Telemetry"`
23+
Autoupdate bool `header:"Autoupdate"`
24+
}{
25+
{Telemetry: cfg.Telemetry.Enabled, Autoupdate: cfg.Autoupdate},
26+
}, configGetOpts.Format, prettyprint.WriterFormatNarrow)
27+
},
28+
}
29+
30+
var configGetOpts struct {
31+
Format formatOpts
32+
}
33+
34+
func init() {
35+
configCmd.AddCommand(configGetCmd)
36+
addFormatFlags(configGetCmd, &configGetOpts.Format)
37+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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 cmd
6+
7+
import (
8+
"log/slog"
9+
10+
"github.com/gitpod-io/local-app/pkg/config"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var configSetCmd = &cobra.Command{
15+
Use: "set",
16+
Short: "Set an individual config value in the config file",
17+
Long: `Set an individual config value in the config file.
18+
19+
Example:
20+
# Disable telemetry
21+
local-app config set --telemetry=false
22+
23+
# Disable autoupdate
24+
local-app config set --autoupdate=false
25+
26+
# Enable telemetry and autoupdate
27+
local-app config set --telemetry=true --autoupdate=true
28+
`,
29+
RunE: func(cmd *cobra.Command, args []string) error {
30+
cmd.SilenceUsage = true
31+
32+
var update bool
33+
cfg := config.FromContext(cmd.Context())
34+
if cmd.Flags().Changed("autoupdate") {
35+
cfg.Autoupdate = configSetOpts.Autoupdate
36+
update = true
37+
}
38+
if cmd.Flags().Changed("telemetry") {
39+
cfg.Telemetry.Enabled = configSetOpts.Telemetry
40+
update = true
41+
}
42+
if !update {
43+
return cmd.Help()
44+
}
45+
46+
slog.Debug("updating config")
47+
err := config.SaveConfig(cfg.Filename, cfg)
48+
if err != nil {
49+
return err
50+
}
51+
return nil
52+
},
53+
}
54+
55+
var configSetOpts struct {
56+
Autoupdate bool
57+
Telemetry bool
58+
}
59+
60+
func init() {
61+
configCmd.AddCommand(configSetCmd)
62+
configSetCmd.Flags().BoolVar(&configSetOpts.Autoupdate, "autoupdate", true, "enable/disable autoupdate")
63+
configSetCmd.Flags().BoolVar(&configSetOpts.Telemetry, "telemetry", true, "enable/disable telemetry")
64+
}

components/local-app/cmd/login.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"log/slog"
1212
"net/url"
1313
"os"
14+
"strings"
1415
"time"
1516

1617
"github.com/bufbuild/connect-go"
@@ -37,6 +38,9 @@ var loginCmd = &cobra.Command{
3738
RunE: func(cmd *cobra.Command, args []string) error {
3839
cmd.SilenceUsage = true
3940

41+
if !strings.HasPrefix(loginOpts.Host, "http") {
42+
loginOpts.Host = "https://" + loginOpts.Host
43+
}
4044
host, err := url.Parse(loginOpts.Host)
4145
if err != nil {
4246
return fmt.Errorf("cannot parse host %s: %w", loginOpts.Host, err)

components/local-app/cmd/root.go

Lines changed: 10 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,17 @@ 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+
if !isVersionCommand(cmd) {
105+
waitForUpdate := selfupdate.Autoupdate(cmd.Context(), cfg)
106+
cmd.PostRunE = func(cmd *cobra.Command, args []string) error {
107+
waitForUpdate()
108+
return nil
109+
}
110+
}
111+
103112
return nil
104113
},
105114
}

components/local-app/cmd/root_test.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ type CommandTestExpectation struct {
3737
Output string
3838
}
3939

40+
// AddActiveTestContext sets the active context to "test" which makes sure we run against the test HTTP server
41+
func AddActiveTestContext(cfg *config.Config) *config.Config {
42+
cfg.ActiveContext = "test"
43+
return cfg
44+
}
45+
4046
func RunCommandTests(t *testing.T, tests []CommandTest) {
4147
for _, test := range tests {
4248
name := test.Name
@@ -69,9 +75,13 @@ func RunCommandTests(t *testing.T, tests []CommandTest) {
6975
}
7076
rootTestingOpts.Client = clnt
7177

78+
testurl, err := url.Parse(apisrv.URL)
79+
if err != nil {
80+
t.Fatal(err)
81+
}
7282
test.Config.Contexts = map[string]*config.ConnectionContext{
7383
"test": {
74-
Host: &config.YamlURL{URL: &url.URL{Scheme: "https", Host: "testing"}},
84+
Host: &config.YamlURL{URL: testurl},
7585
Token: "hello world",
7686
},
7787
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 cmd
6+
7+
import (
8+
"context"
9+
"time"
10+
11+
"github.com/gitpod-io/local-app/pkg/config"
12+
"github.com/gitpod-io/local-app/pkg/constants"
13+
"github.com/gitpod-io/local-app/pkg/selfupdate"
14+
"github.com/sagikazarmark/slog-shim"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
var versionUpdateCmd = &cobra.Command{
19+
Use: "update",
20+
Short: "Updates the CLI to the latest version",
21+
RunE: func(cmd *cobra.Command, args []string) error {
22+
cmd.SilenceUsage = true
23+
24+
dlctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
25+
defer cancel()
26+
27+
cfg := config.FromContext(cmd.Context())
28+
gpctx, err := cfg.GetActiveContext()
29+
if err != nil {
30+
return err
31+
}
32+
33+
mf, err := selfupdate.DownloadManifest(dlctx, gpctx.Host.URL.String())
34+
if err != nil {
35+
return err
36+
}
37+
if !selfupdate.NeedsUpdate(constants.Version, mf) {
38+
slog.Info("already up to date")
39+
return nil
40+
}
41+
42+
slog.Info("updating to latest version " + mf.Version.String())
43+
err = selfupdate.ReplaceSelf(dlctx, mf)
44+
if err != nil {
45+
return err
46+
}
47+
48+
return nil
49+
},
50+
}
51+
52+
func init() {
53+
versionCmd.AddCommand(versionUpdateCmd)
54+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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 cmd
6+
7+
import (
8+
"encoding/json"
9+
"net/http"
10+
"runtime"
11+
"testing"
12+
13+
"github.com/Masterminds/semver/v3"
14+
"github.com/gitpod-io/local-app/pkg/config"
15+
"github.com/gitpod-io/local-app/pkg/constants"
16+
"github.com/gitpod-io/local-app/pkg/selfupdate"
17+
"github.com/opencontainers/go-digest"
18+
)
19+
20+
func TestVersionUpdateCmd(t *testing.T) {
21+
RunCommandTests(t, []CommandTest{
22+
{
23+
Name: "happy path",
24+
Commandline: []string{"version", "update"},
25+
PrepServer: func(mux *http.ServeMux) {
26+
newBinary := []byte("#!/bin/bash\necho hello world")
27+
mux.HandleFunc(selfupdate.GitpodCLIBasePath+"/manifest.json", func(w http.ResponseWriter, r *http.Request) {
28+
mf, err := json.Marshal(selfupdate.Manifest{
29+
Version: semver.MustParse("v9999.0"),
30+
Binaries: []selfupdate.Binary{
31+
{
32+
Filename: "gitpod",
33+
OS: runtime.GOOS,
34+
Arch: runtime.GOARCH,
35+
Digest: digest.FromBytes(newBinary),
36+
},
37+
},
38+
})
39+
if err != nil {
40+
t.Fatal(err)
41+
}
42+
_, _ = w.Write(mf)
43+
})
44+
mux.HandleFunc(selfupdate.GitpodCLIBasePath+"/gitpod", func(w http.ResponseWriter, r *http.Request) {
45+
_, _ = w.Write(newBinary)
46+
})
47+
},
48+
Config: AddActiveTestContext(&config.Config{}),
49+
},
50+
{
51+
Name: "no update needed",
52+
Commandline: []string{"version", "update"},
53+
PrepServer: func(mux *http.ServeMux) {
54+
mux.HandleFunc(selfupdate.GitpodCLIBasePath+"/manifest.json", func(w http.ResponseWriter, r *http.Request) {
55+
mf, err := json.Marshal(selfupdate.Manifest{
56+
Version: constants.Version,
57+
})
58+
if err != nil {
59+
t.Fatal(err)
60+
}
61+
_, _ = w.Write(mf)
62+
})
63+
},
64+
Config: AddActiveTestContext(&config.Config{}),
65+
},
66+
})
67+
}

0 commit comments

Comments
 (0)