diff --git a/components/local-app/BUILD.js b/components/local-app/BUILD.js index dbdfed5646f503..7057deb89b8098 100644 --- a/components/local-app/BUILD.js +++ b/components/local-app/BUILD.js @@ -7,7 +7,7 @@ const generatePackage = function (goos, goarch, binaryName, mainFile) { let pkg = { name, type: "go", - srcs: ["go.mod", "go.sum", "**/*.go"], + srcs: ["go.mod", "go.sum", "**/*.go", "version.txt"], deps: [ "components/supervisor-api/go:lib", "components/gitpod-protocol/go:lib", @@ -19,14 +19,12 @@ const generatePackage = function (goos, goarch, binaryName, mainFile) { packaging: "app", dontTest: dontTest, buildCommand: [ - "go", - "build", - "-trimpath", - "-ldflags", - "-buildid= -w -s -X 'github.com/gitpod-io/local-app/pkg/constants.Version=commit-${__git_commit}'", - "-o", - binaryName, - mainFile, + "sh", + "-c", + '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 ' + + binaryName + + " " + + mainFile, ], }, binaryName, diff --git a/components/local-app/BUILD.yaml b/components/local-app/BUILD.yaml index 9eee7881858b21..7ee84ef443153d 100644 --- a/components/local-app/BUILD.yaml +++ b/components/local-app/BUILD.yaml @@ -3,7 +3,7 @@ packages: - name: docker type: docker deps: - - :app + - :app-with-manifest argdeps: - imageRepoBase config: @@ -13,7 +13,32 @@ packages: image: - ${imageRepoBase}/local-app:${version} - ${imageRepoBase}/local-app:commit-${__git_commit} - + - name: update-manifest + type: go + srcs: + - go.mod + - go.sum + - "**/*.go" + - version.txt + deps: + - components/supervisor-api/go:lib + - components/gitpod-protocol/go:lib + - components/local-app-api/go:lib + - components/public-api/go:lib + config: + packaging: app + dontTest: true + buildCommand: ["go", "build", "-o", "update-manifest", "./main/update-manifest/main.go"] + - name: app-with-manifest + type: generic + deps: + - :app + - :update-manifest + config: + commands: + - ["sh", "-c", "mkdir -p bin && mv components-local-app--app/bin/* bin/"] + - ["sh", "-c", "components-local-app--update-manifest/update-manifest --cwd bin | tee bin/manifest.json"] + - ["rm", "-rf", "components-local-app--update-manifest", "components-local-app--app"] scripts: - name: install-cli description: "Install gitpod-cli as `gitpod` command and add auto-completion. Usage: '. $(leeway run components/local-app:install-cli)'" diff --git a/components/local-app/README.md b/components/local-app/README.md index cdc89852f61525..1eb7b5c30e1bf0 100644 --- a/components/local-app/README.md +++ b/components/local-app/README.md @@ -41,6 +41,11 @@ leeway run components/local-app:install-cli leeway run components/local-app:cli-completion ``` +### Versioning and Release Management + +The CLI is versioned independently of other Gitpod artifacts due to its auto-updating behaviour. +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. + ## local-app **Beware**: this is very much work in progress and will likely break things. diff --git a/components/local-app/cmd/config-get.go b/components/local-app/cmd/config-get.go new file mode 100644 index 00000000000000..6a5bd43e0d877c --- /dev/null +++ b/components/local-app/cmd/config-get.go @@ -0,0 +1,37 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "github.com/gitpod-io/local-app/pkg/config" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/spf13/cobra" +) + +var configGetCmd = &cobra.Command{ + Use: "get", + Short: "Get an individual config value in the config file", + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + cfg := config.FromContext(cmd.Context()) + + return WriteTabular([]struct { + Telemetry bool `header:"Telemetry"` + Autoupdate bool `header:"Autoupdate"` + }{ + {Telemetry: cfg.Telemetry.Enabled, Autoupdate: cfg.Autoupdate}, + }, configGetOpts.Format, prettyprint.WriterFormatNarrow) + }, +} + +var configGetOpts struct { + Format formatOpts +} + +func init() { + configCmd.AddCommand(configGetCmd) + addFormatFlags(configGetCmd, &configGetOpts.Format) +} diff --git a/components/local-app/cmd/config-set.go b/components/local-app/cmd/config-set.go new file mode 100644 index 00000000000000..9cb80dd279746d --- /dev/null +++ b/components/local-app/cmd/config-set.go @@ -0,0 +1,64 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "log/slog" + + "github.com/gitpod-io/local-app/pkg/config" + "github.com/spf13/cobra" +) + +var configSetCmd = &cobra.Command{ + Use: "set", + Short: "Set an individual config value in the config file", + Long: `Set an individual config value in the config file. + +Example: + # Disable telemetry + local-app config set --telemetry=false + + # Disable autoupdate + local-app config set --autoupdate=false + + # Enable telemetry and autoupdate + local-app config set --telemetry=true --autoupdate=true +`, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + var update bool + cfg := config.FromContext(cmd.Context()) + if cmd.Flags().Changed("autoupdate") { + cfg.Autoupdate = configSetOpts.Autoupdate + update = true + } + if cmd.Flags().Changed("telemetry") { + cfg.Telemetry.Enabled = configSetOpts.Telemetry + update = true + } + if !update { + return cmd.Help() + } + + slog.Debug("updating config") + err := config.SaveConfig(cfg.Filename, cfg) + if err != nil { + return err + } + return nil + }, +} + +var configSetOpts struct { + Autoupdate bool + Telemetry bool +} + +func init() { + configCmd.AddCommand(configSetCmd) + configSetCmd.Flags().BoolVar(&configSetOpts.Autoupdate, "autoupdate", true, "enable/disable autoupdate") + configSetCmd.Flags().BoolVar(&configSetOpts.Telemetry, "telemetry", true, "enable/disable telemetry") +} diff --git a/components/local-app/cmd/login.go b/components/local-app/cmd/login.go index 792d034f76e529..86abd7861f35bb 100644 --- a/components/local-app/cmd/login.go +++ b/components/local-app/cmd/login.go @@ -11,6 +11,7 @@ import ( "log/slog" "net/url" "os" + "strings" "time" "github.com/bufbuild/connect-go" @@ -37,6 +38,9 @@ var loginCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true + if !strings.HasPrefix(loginOpts.Host, "http") { + loginOpts.Host = "https://" + loginOpts.Host + } host, err := url.Parse(loginOpts.Host) if err != nil { return fmt.Errorf("cannot parse host %s: %w", loginOpts.Host, err) diff --git a/components/local-app/cmd/root.go b/components/local-app/cmd/root.go index cc5ef83c6d748b..363a866223ec76 100644 --- a/components/local-app/cmd/root.go +++ b/components/local-app/cmd/root.go @@ -19,6 +19,7 @@ import ( "github.com/gitpod-io/local-app/pkg/config" "github.com/gitpod-io/local-app/pkg/constants" "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/gitpod-io/local-app/pkg/selfupdate" "github.com/gitpod-io/local-app/pkg/telemetry" "github.com/gookit/color" "github.com/lmittmann/tint" @@ -97,9 +98,17 @@ var rootCmd = &cobra.Command{ if gpctx, err := cfg.GetActiveContext(); err == nil && gpctx != nil { telemetryEnabled = telemetryEnabled && gpctx.Host.String() == "https://gitpod.io" } - telemetry.Init(telemetryEnabled, cfg.Telemetry.Identity, constants.Version) + telemetry.Init(telemetryEnabled, cfg.Telemetry.Identity, constants.Version.String()) telemetry.RecordCommand(cmd) + if !isVersionCommand(cmd) { + waitForUpdate := selfupdate.Autoupdate(cmd.Context(), cfg) + cmd.PostRunE = func(cmd *cobra.Command, args []string) error { + waitForUpdate() + return nil + } + } + return nil }, } diff --git a/components/local-app/cmd/root_test.go b/components/local-app/cmd/root_test.go index b29f6c7cbe35ee..db037b1da53257 100644 --- a/components/local-app/cmd/root_test.go +++ b/components/local-app/cmd/root_test.go @@ -37,6 +37,12 @@ type CommandTestExpectation struct { Output string } +// AddActiveTestContext sets the active context to "test" which makes sure we run against the test HTTP server +func AddActiveTestContext(cfg *config.Config) *config.Config { + cfg.ActiveContext = "test" + return cfg +} + func RunCommandTests(t *testing.T, tests []CommandTest) { for _, test := range tests { name := test.Name @@ -69,9 +75,13 @@ func RunCommandTests(t *testing.T, tests []CommandTest) { } rootTestingOpts.Client = clnt + testurl, err := url.Parse(apisrv.URL) + if err != nil { + t.Fatal(err) + } test.Config.Contexts = map[string]*config.ConnectionContext{ "test": { - Host: &config.YamlURL{URL: &url.URL{Scheme: "https", Host: "testing"}}, + Host: &config.YamlURL{URL: testurl}, Token: "hello world", }, } diff --git a/components/local-app/cmd/version-update.go b/components/local-app/cmd/version-update.go new file mode 100644 index 00000000000000..b0177e36c7d378 --- /dev/null +++ b/components/local-app/cmd/version-update.go @@ -0,0 +1,54 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "context" + "time" + + "github.com/gitpod-io/local-app/pkg/config" + "github.com/gitpod-io/local-app/pkg/constants" + "github.com/gitpod-io/local-app/pkg/selfupdate" + "github.com/sagikazarmark/slog-shim" + "github.com/spf13/cobra" +) + +var versionUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Updates the CLI to the latest version", + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + dlctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + + cfg := config.FromContext(cmd.Context()) + gpctx, err := cfg.GetActiveContext() + if err != nil { + return err + } + + mf, err := selfupdate.DownloadManifest(dlctx, gpctx.Host.URL.String()) + if err != nil { + return err + } + if !selfupdate.NeedsUpdate(constants.Version, mf) { + slog.Info("already up to date") + return nil + } + + slog.Info("updating to latest version " + mf.Version.String()) + err = selfupdate.ReplaceSelf(dlctx, mf) + if err != nil { + return err + } + + return nil + }, +} + +func init() { + versionCmd.AddCommand(versionUpdateCmd) +} diff --git a/components/local-app/cmd/version-update_test.go b/components/local-app/cmd/version-update_test.go new file mode 100644 index 00000000000000..560690dee3dc94 --- /dev/null +++ b/components/local-app/cmd/version-update_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "encoding/json" + "net/http" + "runtime" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/gitpod-io/local-app/pkg/config" + "github.com/gitpod-io/local-app/pkg/constants" + "github.com/gitpod-io/local-app/pkg/selfupdate" + "github.com/opencontainers/go-digest" +) + +func TestVersionUpdateCmd(t *testing.T) { + RunCommandTests(t, []CommandTest{ + { + Name: "happy path", + Commandline: []string{"version", "update"}, + PrepServer: func(mux *http.ServeMux) { + newBinary := []byte("#!/bin/bash\necho hello world") + mux.HandleFunc(selfupdate.GitpodCLIBasePath+"/manifest.json", func(w http.ResponseWriter, r *http.Request) { + mf, err := json.Marshal(selfupdate.Manifest{ + Version: semver.MustParse("v9999.0"), + Binaries: []selfupdate.Binary{ + { + Filename: "gitpod", + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Digest: digest.FromBytes(newBinary), + }, + }, + }) + if err != nil { + t.Fatal(err) + } + _, _ = w.Write(mf) + }) + mux.HandleFunc(selfupdate.GitpodCLIBasePath+"/gitpod", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(newBinary) + }) + }, + Config: AddActiveTestContext(&config.Config{}), + }, + { + Name: "no update needed", + Commandline: []string{"version", "update"}, + PrepServer: func(mux *http.ServeMux) { + mux.HandleFunc(selfupdate.GitpodCLIBasePath+"/manifest.json", func(w http.ResponseWriter, r *http.Request) { + mf, err := json.Marshal(selfupdate.Manifest{ + Version: constants.Version, + }) + if err != nil { + t.Fatal(err) + } + _, _ = w.Write(mf) + }) + }, + Config: AddActiveTestContext(&config.Config{}), + }, + }) +} diff --git a/components/local-app/cmd/version.go b/components/local-app/cmd/version.go new file mode 100644 index 00000000000000..8c12f9c507f8d9 --- /dev/null +++ b/components/local-app/cmd/version.go @@ -0,0 +1,67 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package cmd + +import ( + "context" + "time" + + "github.com/gitpod-io/local-app/pkg/constants" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/gitpod-io/local-app/pkg/selfupdate" + "github.com/sagikazarmark/slog-shim" + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Prints the CLI version", + RunE: func(cmd *cobra.Command, args []string) error { + type Version struct { + Version string `print:"version"` + GitCommit string `print:"commit"` + BuildTime string `print:"built at"` + Latest string `print:"latest"` + } + v := Version{ + Version: constants.Version.String(), + GitCommit: constants.GitCommit, + BuildTime: constants.MustParseBuildTime().Format(time.RFC3339), + Latest: "", + } + + if !versionOpts.DontCheckLatest { + dlctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + mf, err := selfupdate.DownloadManifestFromActiveContext(dlctx) + if err != nil { + slog.Debug("cannot download manifest", "err", err) + } + if mf != nil { + v.Latest = mf.Version.String() + } + } + + return WriteTabular([]Version{v}, versionOpts.Format, prettyprint.WriterFormatNarrow) + }, +} + +var versionOpts struct { + Format formatOpts + DontCheckLatest bool +} + +func init() { + rootCmd.AddCommand(versionCmd) + addFormatFlags(versionCmd, &versionOpts.Format) + + versionCmd.Flags().BoolVar(&versionOpts.DontCheckLatest, "dont-check-latest", false, "Don't check for the latest available version") +} + +// isVersionCommand returns true if the given command is a version command +func isVersionCommand(cmd *cobra.Command) bool { + return cmd == versionCmd || cmd.Parent() == versionCmd +} diff --git a/components/local-app/go.mod b/components/local-app/go.mod index 2417b01a2badc5..a4e85a44b7fbcb 100644 --- a/components/local-app/go.mod +++ b/components/local-app/go.mod @@ -51,14 +51,17 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/kr/binarydist v0.1.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/sftp v1.13.5 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/sanbornm/go-selfupdate v0.0.0-20230714125711-e1c03e3d6ac7 // indirect github.com/segmentio/backo-go v1.0.0 // indirect github.com/sergi/go-diff v1.1.0 // indirect github.com/skeema/knownhosts v1.2.0 // indirect @@ -71,6 +74,7 @@ require ( ) require ( + github.com/Masterminds/semver/v3 v3.2.1 github.com/cenkalti/backoff/v4 v4.1.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/danieljoos/wincred v1.1.0 // indirect @@ -82,6 +86,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.17.0 // indirect github.com/melbahja/goph v1.4.0 + github.com/opencontainers/go-digest v1.0.0 github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/segmentio/analytics-go/v3 v3.3.0 github.com/sourcegraph/jsonrpc2 v0.0.0-20200429184054-15c2290dcb37 // indirect diff --git a/components/local-app/go.sum b/components/local-app/go.sum index 07c82d2b32ffec..a0224c7ecd3958 100644 --- a/components/local-app/go.sum +++ b/components/local-app/go.sum @@ -1,5 +1,7 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= @@ -99,6 +101,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3 h1:lLT7ZLSzGLI08vc9cpd+tYmNWjd github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= github.com/improbable-eng/grpc-web v0.14.0 h1:GdoK+cXABdB+1keuqsV1drSFO2XLYIxqt/4Rj8SWGBk= github.com/improbable-eng/grpc-web v0.14.0/go.mod h1:6hRR09jOEG81ADP5wCQju1z71g6OL4eEvELdran/3cs= +github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8= +github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -112,6 +116,8 @@ github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/binarydist v0.1.0 h1:6kAoLA9FMMnNGSehX0s1PdjbEaACznAv/W219j2uvyo= +github.com/kr/binarydist v0.1.0/go.mod h1:DY7S//GCoz1BCd0B0EVrinCKAZN3pXe+MDaIZbXQVgM= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -144,6 +150,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -161,6 +169,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sanbornm/go-selfupdate v0.0.0-20230714125711-e1c03e3d6ac7 h1:gm8c4AGd1rHqUsWJQHCjJlAg7yEcgzwyHhWbspPk9HI= +github.com/sanbornm/go-selfupdate v0.0.0-20230714125711-e1c03e3d6ac7/go.mod h1:aCbVFjQ5FBm4o5D/JVxc8AtWfGgDYnDXgySPJf91ozU= github.com/segmentio/analytics-go/v3 v3.3.0 h1:8VOMaVGBW03pdBrj1CMFfY9o/rnjJC+1wyQHlVxjw5o= github.com/segmentio/analytics-go/v3 v3.3.0/go.mod h1:p8owAF8X+5o27jmvUognuXxdtqvSGtD0ZrfY2kcS9bE= github.com/segmentio/backo-go v1.0.0 h1:kbOAtGJY2DqOR0jfRkYEorx/b18RgtepGtY3+Cpe6qA= diff --git a/components/local-app/leeway.Dockerfile b/components/local-app/leeway.Dockerfile index 299bbb10b4b59b..f359d3b85f4445 100644 --- a/components/local-app/leeway.Dockerfile +++ b/components/local-app/leeway.Dockerfile @@ -5,7 +5,7 @@ FROM cgr.dev/chainguard/wolfi-base:latest@sha256:5fd82be4dfccc650c1985d57847f1af556bf23a8a5d86ec5fddf7d417ab147a4 WORKDIR /app -COPY components-local-app--app/bin/* ./ +COPY components-local-app--app-with-manifest/bin/* ./ ARG __GIT_COMMIT ARG VERSION diff --git a/components/local-app/main/gitpod-local-companion/main.go b/components/local-app/main/gitpod-local-companion/main.go index 57b50a68ee666c..cd4f876401a217 100644 --- a/components/local-app/main/gitpod-local-companion/main.go +++ b/components/local-app/main/gitpod-local-companion/main.go @@ -40,7 +40,7 @@ func main() { Usage: "connect your Gitpod workspaces", Action: DefaultCommand("run"), EnableBashCompletion: true, - Version: constants.Version, + Version: constants.Version.String(), Flags: []cli.Flag{ &cli.StringFlag{ Name: "gitpod-host", @@ -283,7 +283,7 @@ func tryConnectToServer(gitpodUrl string, tkn string, reconnectionHandler func() CloseHandler: closeHandler, ExtraHeaders: map[string]string{ "User-Agent": "gitpod/local-companion", - "X-Client-Version": constants.Version, + "X-Client-Version": constants.Version.String(), }, }) if err != nil { diff --git a/components/local-app/main/update-manifest/main.go b/components/local-app/main/update-manifest/main.go new file mode 100644 index 00000000000000..4746315bc63f39 --- /dev/null +++ b/components/local-app/main/update-manifest/main.go @@ -0,0 +1,39 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/Masterminds/semver/v3" + "github.com/gitpod-io/local-app/pkg/constants" + "github.com/gitpod-io/local-app/pkg/selfupdate" + "github.com/sagikazarmark/slog-shim" + "github.com/spf13/pflag" +) + +var ( + version = pflag.String("version", constants.Version.String(), "version to use") + cwd = pflag.String("cwd", ".", "working directory") +) + +func main() { + pflag.Parse() + + ver := semver.MustParse(*version) + mf, err := selfupdate.GenerateManifest(ver, *cwd, selfupdate.DefaultFilenameParser) + if err != nil { + slog.Error("cannot generate manifest", "err", err) + os.Exit(1) + } + fc, err := json.MarshalIndent(mf, "", " ") + if err != nil { + slog.Error("cannot marshal manifest", "err", err) + os.Exit(1) + } + fmt.Println(string(fc)) +} diff --git a/components/local-app/pkg/config/config.go b/components/local-app/pkg/config/config.go new file mode 100644 index 00000000000000..fdc9456126ce25 --- /dev/null +++ b/components/local-app/pkg/config/config.go @@ -0,0 +1,115 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package config + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/gitpod-io/local-app/pkg/telemetry" + "gopkg.in/yaml.v3" +) + +const DEFAULT_LOCATION = "~/.gitpod/config.yaml" + +type Config struct { + Filename string `yaml:"-"` + + ActiveContext string `yaml:"activeContext,omitempty"` + Contexts map[string]*ConnectionContext + Telemetry Telemetry `yaml:"telemetry"` + Autoupdate bool `yaml:"autoupdate"` +} + +type Telemetry struct { + Enabled bool `yaml:"enabled"` + Identity string `yaml:"identity,omitempty"` +} + +func FromBool(v *bool) bool { + if v == nil { + return false + } + return *v +} + +func Bool(v bool) *bool { + return &v +} + +func SaveConfig(fn string, cfg *Config) error { + fc, err := yaml.Marshal(cfg) + if err != nil { + return err + } + _ = os.MkdirAll(filepath.Dir(fn), 0755) + + err = os.WriteFile(fn, fc, 0644) + if err != nil { + return err + } + return nil +} + +func DefaultConfig() *Config { + return &Config{ + Filename: DEFAULT_LOCATION, + Contexts: make(map[string]*ConnectionContext), + } +} + +// LoadConfig loads the configuration from a file. If the file does not exist, it returns a default configuration. +// This function never returns nil, even in case of an error. If an error is returned, this function also returns the default configuration. +func LoadConfig(fn string) (res *Config, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("failed to load config from %s: %w", fn, err) + } + }() + + if strings.HasPrefix(fn, "~/") { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + fn = filepath.Join(homeDir, fn[2:]) + } + + cfg := &Config{ + Filename: fn, + Contexts: make(map[string]*ConnectionContext), + Telemetry: Telemetry{ + Enabled: !telemetry.DoNotTrack(), + Identity: telemetry.GenerateIdentity(), + }, + Autoupdate: true, + } + fc, err := os.ReadFile(fn) + if err != nil { + return cfg, err + } + err = yaml.Unmarshal(fc, &cfg) + if err != nil { + return cfg, err + } + + return cfg, nil +} + +type configContextKeyTpe struct{} + +var configContextKey = configContextKeyTpe{} + +func ToContext(ctx context.Context, cfg *Config) context.Context { + return context.WithValue(ctx, configContextKey, cfg) +} + +func FromContext(ctx context.Context) *Config { + cfg, _ := ctx.Value(configContextKey).(*Config) + return cfg +} diff --git a/components/local-app/pkg/config/context.go b/components/local-app/pkg/config/context.go index de5f3c917ec928..f75b8a3d55c995 100644 --- a/components/local-app/pkg/config/context.go +++ b/components/local-app/pkg/config/context.go @@ -5,33 +5,13 @@ package config import ( - "context" "fmt" "net/url" - "os" - "path/filepath" - "strings" "github.com/gitpod-io/local-app/pkg/prettyprint" - "github.com/gitpod-io/local-app/pkg/telemetry" "gopkg.in/yaml.v3" ) -const DEFAULT_LOCATION = "~/.gitpod/config.yaml" - -type Config struct { - Filename string `yaml:"-"` - - ActiveContext string `yaml:"activeContext,omitempty"` - Contexts map[string]*ConnectionContext - Telemetry *Telemetry `yaml:"telemetry,omitempty"` -} - -type Telemetry struct { - Enabled bool `yaml:"enabled,omitempty"` - Identity string `yaml:"identity,omitempty"` -} - func (c *Config) GetActiveContext() (*ConnectionContext, error) { if c == nil { return nil, ErrNoContext @@ -83,74 +63,3 @@ func (u *YamlURL) UnmarshalYAML(value *yaml.Node) error { func (u *YamlURL) MarshalYAML() (interface{}, error) { return u.String(), nil } - -func SaveConfig(fn string, cfg *Config) error { - fc, err := yaml.Marshal(cfg) - if err != nil { - return err - } - _ = os.MkdirAll(filepath.Dir(fn), 0755) - - err = os.WriteFile(fn, fc, 0644) - if err != nil { - return err - } - return nil -} - -func DefaultConfig() *Config { - return &Config{ - Filename: DEFAULT_LOCATION, - Contexts: make(map[string]*ConnectionContext), - } -} - -// LoadConfig loads the configuration from a file. If the file does not exist, it returns a default configuration. -// This function never returns nil, even in case of an error. If an error is returned, this function also returns the default configuration. -func LoadConfig(fn string) (res *Config, err error) { - defer func() { - if err != nil { - err = fmt.Errorf("failed to load config from %s: %w", fn, err) - } - }() - - if strings.HasPrefix(fn, "~/") { - homeDir, err := os.UserHomeDir() - if err != nil { - return nil, err - } - fn = filepath.Join(homeDir, fn[2:]) - } - - cfg := &Config{ - Filename: fn, - Contexts: make(map[string]*ConnectionContext), - Telemetry: &Telemetry{ - Enabled: !telemetry.DoNotTrack(), - Identity: telemetry.GenerateIdentity(), - }, - } - fc, err := os.ReadFile(fn) - if err != nil { - return cfg, err - } - err = yaml.Unmarshal(fc, &cfg) - if err != nil { - return cfg, err - } - - return cfg, nil -} - -type configContextKeyTpe struct{} - -var configContextKey = configContextKeyTpe{} - -func ToContext(ctx context.Context, cfg *Config) context.Context { - return context.WithValue(ctx, configContextKey, cfg) -} - -func FromContext(ctx context.Context) *Config { - cfg, _ := ctx.Value(configContextKey).(*Config) - return cfg -} diff --git a/components/local-app/pkg/constants/constants.go b/components/local-app/pkg/constants/constants.go index ab66d7e1060b6e..c46baa9529a6c4 100644 --- a/components/local-app/pkg/constants/constants.go +++ b/components/local-app/pkg/constants/constants.go @@ -4,7 +4,37 @@ package constants +import ( + _ "embed" + "fmt" + "strconv" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + version "github.com/gitpod-io/local-app" +) + var ( - // Version - set during build - Version = "dev" + // Version is fed from the main CLI version + Version = semver.MustParse(strings.TrimSpace(version.Version)) + + // GitCommit - set during build + GitCommit = "unknown" + + // BuildTime - set during build + BuildTime = "unknown" ) + +// MustParseBuildTime parses the build time or panics +func MustParseBuildTime() time.Time { + if BuildTime == "unknown" { + return time.Time{} + } + + sec, err := strconv.ParseInt(BuildTime, 10, 64) + if err != nil { + panic(fmt.Sprintf("cannot parse build time: %v", err)) + } + return time.Unix(sec, 0) +} diff --git a/components/local-app/pkg/selfupdate/selfupdate.go b/components/local-app/pkg/selfupdate/selfupdate.go new file mode 100644 index 00000000000000..992c399a30d6be --- /dev/null +++ b/components/local-app/pkg/selfupdate/selfupdate.go @@ -0,0 +1,262 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package selfupdate + +import ( + "context" + "crypto" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/gitpod-io/local-app/pkg/config" + "github.com/gitpod-io/local-app/pkg/constants" + "github.com/gitpod-io/local-app/pkg/prettyprint" + "github.com/inconshreveable/go-update" + "github.com/opencontainers/go-digest" +) + +const ( + // GitpodCLIBasePath is the path relative to a Gitpod installation where the latest + // binary and manifest can be found. + GitpodCLIBasePath = "/static/bin" +) + +// Manifest is the manifest of a selfupdate +type Manifest struct { + Version *semver.Version `json:"version"` + Binaries []Binary `json:"binaries"` +} + +// Binary describes a single executable binary +type Binary struct { + // URL is added when the manifest is downloaded. + URL string `json:"-"` + + Filename string `json:"filename"` + OS string `json:"os"` + Arch string `json:"arch"` + Digest digest.Digest `json:"digest"` +} + +type FilenameParserFunc func(filename string) (os, arch string, ok bool) + +var regexDefaultFilenamePattern = regexp.MustCompile(`.*-(linux|darwin|windows)-(amd64|arm64)(\.exe)?`) + +func DefaultFilenameParser(filename string) (os, arch string, ok bool) { + matches := regexDefaultFilenamePattern.FindStringSubmatch(filename) + if matches == nil { + return "", "", false + } + + return matches[1], matches[2], true +} + +// GenerateManifest generates a manifest for the given location +// by scanning the location for binaries following the naming convention +func GenerateManifest(version *semver.Version, loc string, filenameParser FilenameParserFunc) (*Manifest, error) { + files, err := os.ReadDir(loc) + if err != nil { + return nil, err + } + + var binaries []Binary + for _, f := range files { + goos, arch, ok := filenameParser(f.Name()) + if !ok { + continue + } + + fd, err := os.Open(filepath.Join(loc, f.Name())) + if err != nil { + return nil, err + } + dgst, err := digest.FromReader(fd) + fd.Close() + if err != nil { + return nil, err + } + + binaries = append(binaries, Binary{ + Filename: f.Name(), + OS: goos, + Arch: arch, + Digest: dgst, + }) + } + + return &Manifest{ + Version: version, + Binaries: binaries, + }, nil +} + +// DownloadManifest downloads a manifest from the given URL. +// Expects the manifest to be at /manifest.json. +func DownloadManifest(ctx context.Context, baseURL string) (res *Manifest, err error) { + defer func() { + if err != nil { + pth := strings.TrimSuffix(baseURL, "/") + GitpodCLIBasePath + err = prettyprint.AddResolution(fmt.Errorf("cannot download manifest from %s/manifest.json: %w", pth, err), + "make sure you are connected to the internet", + "make sure you can reach "+baseURL, + ) + } + }() + + murl, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + murl.Path = filepath.Join(murl.Path, GitpodCLIBasePath) + + originalPath := murl.Path + murl.Path = filepath.Join(murl.Path, "manifest.json") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, murl.String(), 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, fmt.Errorf(resp.Status) + } + + var mf Manifest + err = json.NewDecoder(resp.Body).Decode(&mf) + if err != nil { + return nil, err + } + for i := range mf.Binaries { + murl.Path = filepath.Join(originalPath, mf.Binaries[i].Filename) + mf.Binaries[i].URL = murl.String() + } + + return &mf, nil +} + +// DownloadManifestFromActiveContext downloads the manifest from the active configuration context +func DownloadManifestFromActiveContext(ctx context.Context) (res *Manifest, err error) { + cfg := config.FromContext(ctx) + if cfg == nil { + return nil, nil + } + + gpctx, _ := cfg.GetActiveContext() + if gpctx == nil { + slog.Debug("no active context - autoupdate disabled") + return + } + + mfctx, cancel := context.WithTimeout(ctx, 1*time.Second) + defer cancel() + mf, err := DownloadManifest(mfctx, gpctx.Host.URL.String()) + if err != nil { + return + } + + return mf, nil +} + +// NeedsUpdate checks if the current version is outdated +func NeedsUpdate(current *semver.Version, manifest *Manifest) bool { + return manifest.Version.GreaterThan(current) +} + +// ReplaceSelf replaces the current binary with the one from the manifest, no matter the version +// If there is no matching binary in the manifest, this function returns ErrNoBinaryAvailable. +func ReplaceSelf(ctx context.Context, manifest *Manifest) error { + var binary *Binary + for _, b := range manifest.Binaries { + if b.OS != runtime.GOOS || b.Arch != runtime.GOARCH { + continue + } + + binary = &b + break + } + if binary == nil { + return ErrNoBinaryAvailable + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, binary.URL, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + dgst, _ := hex.DecodeString(binary.Digest.Hex()) + return update.Apply(resp.Body, update.Options{ + Checksum: dgst, + Hash: crypto.SHA256, + TargetMode: 0755, + }) +} + +var ErrNoBinaryAvailable = errors.New("no binary available for this platform") + +// Autoupdate checks if there is a newer version available and updates the binary if so +// actually updates. This function returns immediately and runs the update in the background. +// The returned function can be used to wait for the update to finish. +func Autoupdate(ctx context.Context, cfg *config.Config) func() { + if !cfg.Autoupdate { + return func() {} + } + + done := make(chan struct{}) + go func() { + defer close(done) + + var err error + defer func() { + if err != nil { + slog.Debug("version check failed", "err", err) + } + }() + mf, err := DownloadManifestFromActiveContext(ctx) + if err != nil { + return + } + if mf == nil { + slog.Debug("no selfupdate version manifest available") + return + } + + if !NeedsUpdate(constants.Version, mf) { + slog.Debug("no update available", "current", constants.Version, "latest", mf.Version) + return + } + + slog.Warn("new version available - run `"+os.Args[0]+" version update` to update", "current", constants.Version, "latest", mf.Version) + }() + + return func() { + select { + case <-done: + return + case <-time.After(5 * time.Second): + slog.Warn("version check is still running - press Ctrl+C to abort") + } + } +} diff --git a/components/local-app/pkg/selfupdate/selfupdate_test.go b/components/local-app/pkg/selfupdate/selfupdate_test.go new file mode 100644 index 00000000000000..6e1cda8560c60f --- /dev/null +++ b/components/local-app/pkg/selfupdate/selfupdate_test.go @@ -0,0 +1,240 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package selfupdate + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/google/go-cmp/cmp" + "github.com/opencontainers/go-digest" +) + +func TestGenerateManifest(t *testing.T) { + type Expectation struct { + Error string + Manifest *Manifest + } + tests := []struct { + Name string + Expectation Expectation + Files []string + FilenameParser FilenameParserFunc + }{ + { + Name: "happy path", + Expectation: Expectation{ + Manifest: &Manifest{ + Version: semver.MustParse("v1.0"), + Binaries: []Binary{ + { + Filename: "gitpod-linux-amd64", + OS: "linux", + Arch: "amd64", + Digest: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + }, + }, + }, + }, + Files: []string{ + "gitpod-linux-amd64", + "nonsense", + }, + FilenameParser: DefaultFilenameParser, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + var act Expectation + + loc, err := os.MkdirTemp("", "selfupdate") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + os.RemoveAll(loc) + }) + for _, f := range test.Files { + err = os.WriteFile(filepath.Join(loc, f), []byte("test"), 0644) + if err != nil { + t.Fatal(err) + } + } + + res, err := GenerateManifest(semver.MustParse("v1.0"), loc, test.FilenameParser) + if err != nil { + act.Error = err.Error() + } + act.Manifest = res + + if diff := cmp.Diff(test.Expectation, act); diff != "" { + t.Errorf("GenerateManifest() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestDownloadManifest(t *testing.T) { + marshal := func(mf *Manifest) []byte { + fc, err := json.Marshal(mf) + if err != nil { + t.Fatal(err) + } + return fc + } + + type Expectation struct { + Error string + Manifest *Manifest + } + tests := []struct { + Name string + Expectation func(url string) Expectation + Manifest []byte + }{ + { + Name: "happy path", + Manifest: marshal(&Manifest{ + Version: semver.MustParse("0.2.0"), + Binaries: []Binary{ + { + Filename: "gitpod-linux-amd64", + OS: "linux", + Arch: "amd64", + Digest: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + }, + }, + }), + Expectation: func(url string) Expectation { + return Expectation{ + Manifest: &Manifest{ + Version: semver.MustParse("0.2.0"), + Binaries: []Binary{ + { + URL: url + GitpodCLIBasePath + "/gitpod-linux-amd64", + Filename: "gitpod-linux-amd64", + OS: "linux", + Arch: "amd64", + Digest: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + }, + }, + }, + } + }, + }, + { + Name: "not found", + Expectation: func(url string) Expectation { + return Expectation{ + Error: "cannot download manifest from " + url + GitpodCLIBasePath + "/manifest.json: 404 Not Found", + } + }, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + mux := http.NewServeMux() + if test.Manifest != nil { + mux.Handle(filepath.Join(GitpodCLIBasePath, "/manifest.json"), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(test.Manifest) + })) + } + srv := httptest.NewServer(mux) + t.Cleanup(func() { srv.Close() }) + + var act Expectation + res, err := DownloadManifest(context.Background(), srv.URL) + if err != nil { + act.Error = err.Error() + } + act.Manifest = res + + if diff := cmp.Diff(test.Expectation(srv.URL), act); diff != "" { + t.Errorf("DownloadManifest() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestReplaceSelf(t *testing.T) { + type File struct { + OS string + Arch string + Filename string + Content []byte + } + type Expectation struct { + Error string + } + tests := []struct { + Name string + Expectation Expectation + Files []File + }{ + { + Name: "happy path", + Files: []File{ + { + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Filename: "gitpod-" + runtime.GOOS + "-" + runtime.GOARCH, + Content: []byte("#!/bin/sh"), + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + var mf Manifest + mf.Version = semver.MustParse("v1.0") + for _, f := range test.Files { + mf.Binaries = append(mf.Binaries, Binary{ + OS: f.OS, + Arch: f.Arch, + Filename: f.Filename, + Digest: digest.FromBytes(f.Content), + }) + } + + mux := http.NewServeMux() + mux.Handle(GitpodCLIBasePath+"/manifest.json", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(mf) + })) + for _, f := range test.Files { + mux.Handle(GitpodCLIBasePath+"/"+f.Filename, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(f.Content) + })) + } + srv := httptest.NewServer(mux) + t.Cleanup(func() { srv.Close() }) + + dlmf, err := DownloadManifest(context.Background(), srv.URL) + if err != nil { + t.Fatal(err) + } + + var act Expectation + + err = ReplaceSelf(context.Background(), dlmf) + if err != nil { + act.Error = err.Error() + } + + if diff := cmp.Diff(test.Expectation, act); diff != "" { + t.Errorf("ReplaceSelf() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/components/local-app/version.go b/components/local-app/version.go new file mode 100644 index 00000000000000..4f366e775d317d --- /dev/null +++ b/components/local-app/version.go @@ -0,0 +1,10 @@ +// Copyright (c) 2023 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package version + +import _ "embed" + +//go:embed "version.txt" +var Version string diff --git a/components/local-app/version.txt b/components/local-app/version.txt new file mode 100644 index 00000000000000..6e8bf73aa550d4 --- /dev/null +++ b/components/local-app/version.txt @@ -0,0 +1 @@ +0.1.0