From 59a0a209646960bfc3fbd7576a9513b9b76dfd8e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 17 Aug 2021 12:14:31 +0100 Subject: [PATCH] feat: Add update command to coder-cli (#417) * feat: Add update command to coder-cli This commit adds a new update subcommand that queries a Coder instance for its current version, fetches the corresponding version from GitHub releases if required, and updates the binary in-place. --- docs/coder.md | 1 + docs/coder_update.md | 31 ++ go.mod | 2 + go.sum | 3 + internal/cmd/cmd.go | 1 + internal/cmd/update.go | 490 ++++++++++++++++++++++++++++++++ internal/cmd/update_test.go | 545 ++++++++++++++++++++++++++++++++++++ internal/version/version.go | 2 +- 8 files changed, 1074 insertions(+), 1 deletion(-) create mode 100644 docs/coder_update.md create mode 100644 internal/cmd/update.go create mode 100644 internal/cmd/update_test.go diff --git a/docs/coder.md b/docs/coder.md index 17e7fa7f..513efb42 100644 --- a/docs/coder.md +++ b/docs/coder.md @@ -20,6 +20,7 @@ coder provides a CLI for working with an existing Coder installation * [coder ssh](coder_ssh.md) - Enter a shell of execute a command over SSH into a Coder workspace * [coder sync](coder_sync.md) - Establish a one way directory sync to a Coder workspace * [coder tokens](coder_tokens.md) - manage Coder API tokens for the active user +* [coder update](coder_update.md) - Update coder binary * [coder urls](coder_urls.md) - Interact with workspace DevURLs * [coder users](coder_users.md) - Interact with Coder user accounts * [coder workspaces](coder_workspaces.md) - Interact with Coder workspaces diff --git a/docs/coder_update.md b/docs/coder_update.md new file mode 100644 index 00000000..8bcc9fae --- /dev/null +++ b/docs/coder_update.md @@ -0,0 +1,31 @@ +## coder update + +Update coder binary + +### Synopsis + +Update coder to the version matching a given coder instance. + +``` +coder update [flags] +``` + +### Options + +``` + --coder string query this coder instance for the matching version + --force do not prompt for confirmation + -h, --help help for update + --version string explicitly specify which version to fetch and install +``` + +### Options inherited from parent commands + +``` + -v, --verbose show verbose output +``` + +### SEE ALSO + +* [coder](coder.md) - coder provides a CLI for working with an existing Coder installation + diff --git a/go.mod b/go.mod index db32d750..e67ff7f2 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.14 require ( cdr.dev/slog v1.4.1 cdr.dev/wsep v0.0.0-20200728013649-82316a09813f + github.com/Masterminds/semver/v3 v3.1.1 github.com/briandowns/spinner v1.16.0 github.com/cli/safeexec v1.0.0 github.com/fatih/color v1.12.0 @@ -23,6 +24,7 @@ require ( github.com/pion/webrtc/v3 v3.0.32 github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 github.com/rjeczalik/notify v0.9.2 + github.com/spf13/afero v1.6.0 github.com/spf13/cobra v1.2.1 github.com/stretchr/testify v1.7.0 golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 diff --git a/go.sum b/go.sum index 755babf6..0435e11b 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0= +github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= @@ -378,6 +380,7 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5I github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw= diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 26df6bc4..90911c07 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -38,6 +38,7 @@ func Make() *cobra.Command { tagsCmd(), tokensCmd(), tunnelCmd(), + updateCmd(), urlCmd(), usersCmd(), workspacesCmd(), diff --git a/internal/cmd/update.go b/internal/cmd/update.go new file mode 100644 index 00000000..015b807e --- /dev/null +++ b/internal/cmd/update.go @@ -0,0 +1,490 @@ +package cmd + +import ( + "archive/tar" + "archive/zip" + "bufio" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "strings" + "time" + + "golang.org/x/xerrors" + + "cdr.dev/coder-cli/internal/config" + "cdr.dev/coder-cli/internal/version" + "cdr.dev/coder-cli/pkg/clog" + + "github.com/Masterminds/semver/v3" + "github.com/manifoldco/promptui" + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +const ( + goosWindows = "windows" + goosLinux = "linux" + apiPrivateVersion = "/api/private/version" +) + +// updater updates coder-cli. +type updater struct { + confirmF func(string) (string, error) + execF func(context.Context, string, ...string) ([]byte, error) + executablePath string + fs afero.Fs + httpClient getter + osF func() string + versionF func() string +} + +func updateCmd() *cobra.Command { + var ( + force bool + coderURL string + versionArg string + ) + + cmd := &cobra.Command{ + Use: "update", + Short: "Update coder binary", + Long: "Update coder to the version matching a given coder instance.", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + + currExe, err := os.Executable() + if err != nil { + return clog.Fatal("init: get current executable", clog.Causef(err.Error())) + } + + updater := &updater{ + confirmF: defaultConfirm, + execF: defaultExec, + executablePath: currExe, + httpClient: httpClient, + fs: afero.NewOsFs(), + osF: func() string { return runtime.GOOS }, + versionF: func() string { return version.Version }, + } + return updater.Run(ctx, force, coderURL, versionArg) + }, + } + + cmd.Flags().BoolVar(&force, "force", false, "do not prompt for confirmation") + cmd.Flags().StringVar(&coderURL, "coder", "", "query this coder instance for the matching version") + cmd.Flags().StringVar(&versionArg, "version", "", "explicitly specify which version to fetch and install") + + return cmd +} + +type getter interface { + Get(url string) (*http.Response, error) +} + +func (u *updater) Run(ctx context.Context, force bool, coderURLArg string, versionArg string) error { + // Check under following directories and warn if coder binary is under them: + // * C:\Windows\ + // * homebrew prefix + // * coder assets root (/var/tmp/coder) + var pathBlockList = []string{ + `C:\Windows\`, + `/var/tmp/coder`, + } + brewPrefixCmd, err := u.execF(ctx, "brew", "--prefix") + if err == nil { // ignore errors if homebrew not installed + pathBlockList = append(pathBlockList, strings.TrimSpace(string(brewPrefixCmd))) + } + + for _, prefix := range pathBlockList { + if HasFilePathPrefix(u.executablePath, prefix) { + return clog.Fatal( + "cowardly refusing to update coder binary", + clog.BlankLine, + clog.Causef("executable path %q is under blocklisted prefix %q", u.executablePath, prefix)) + } + } + + currentBinaryStat, err := u.fs.Stat(u.executablePath) + if err != nil { + return clog.Fatal("preflight: cannot stat current binary", clog.Causef(err.Error())) + } + + if currentBinaryStat.Mode().Perm()&0222 == 0 { + return clog.Fatal("preflight: missing write permission on current binary") + } + + clog.LogInfo(fmt.Sprintf("Current version of coder-cli is %s", version.Version)) + + desiredVersion, err := getDesiredVersion(u.httpClient, coderURLArg, versionArg) + if err != nil { + return clog.Fatal("failed to determine desired version of coder", clog.Causef(err.Error())) + } + + currentVersion, err := semver.StrictNewVersion(u.versionF()) + if err != nil { + clog.LogWarn("failed to determine current version of coder-cli", clog.Causef(err.Error())) + } else if currentVersion.Compare(desiredVersion) == 0 { + clog.LogInfo("Up to date!") + return nil + } + + if !force { + label := fmt.Sprintf("Do you want to download version %s instead", desiredVersion) + if _, err := u.confirmF(label); err != nil { + return clog.Fatal("user cancelled operation", clog.Tipf(`use "--force" to update without confirmation`)) + } + } + + downloadURL, err := queryGithubAssetURL(u.httpClient, desiredVersion, u.osF()) + if err != nil { + return clog.Fatal("failed to query github assets url", clog.Causef(err.Error())) + } + + var downloadBuf bytes.Buffer + memWriter := bufio.NewWriter(&downloadBuf) + + clog.LogInfo("fetching coder-cli from GitHub releases", downloadURL) + resp, err := u.httpClient.Get(downloadURL) + if err != nil { + return clog.Fatal(fmt.Sprintf("failed to fetch URL %s", downloadURL), clog.Causef(err.Error())) + } + + if resp.StatusCode != http.StatusOK { + return clog.Fatal("failed to fetch release", clog.Causef("URL %s returned status code %d", downloadURL, resp.StatusCode)) + } + + if _, err := io.Copy(memWriter, resp.Body); err != nil { + return clog.Fatal(fmt.Sprintf("failed to download %s", downloadURL), clog.Causef(err.Error())) + } + + _ = resp.Body.Close() + + if err := memWriter.Flush(); err != nil { + return clog.Fatal(fmt.Sprintf("failed to save %s", downloadURL), clog.Causef(err.Error())) + } + + // TODO: validate the checksum of the downloaded file. GitHub does not currently provide this information + // and we do not generate them yet. + var updatedBinaryName string + if u.osF() == "windows" { + updatedBinaryName = "coder.exe" + } else { + updatedBinaryName = "coder" + } + updatedBinary, err := extractFromArchive(updatedBinaryName, downloadBuf.Bytes()) + if err != nil { + return clog.Fatal("failed to extract coder binary from archive", clog.Causef(err.Error())) + } + + // We assume the binary is named coder and write it to coder.new + updatedCoderBinaryPath := u.executablePath + ".new" + updatedBin, err := u.fs.OpenFile(updatedCoderBinaryPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, currentBinaryStat.Mode().Perm()) + if err != nil { + return clog.Fatal("failed to create file for updated coder binary", clog.Causef(err.Error())) + } + + fsWriter := bufio.NewWriter(updatedBin) + if _, err := io.Copy(fsWriter, bytes.NewReader(updatedBinary)); err != nil { + return clog.Fatal("failed to write updated coder binary to disk", clog.Causef(err.Error())) + } + + if err := fsWriter.Flush(); err != nil { + return clog.Fatal("failed to persist updated coder binary to disk", clog.Causef(err.Error())) + } + + _ = updatedBin.Close() + + if err := u.doUpdate(ctx, updatedCoderBinaryPath); err != nil { + return clog.Fatal("failed to update coder binary", clog.Causef(err.Error())) + } + + clog.LogSuccess("Updated coder CLI to version " + desiredVersion.String()) + return nil +} + +func (u *updater) doUpdate(ctx context.Context, updatedCoderBinaryPath string) error { + var err error + // TODO(cian): on Windows, we must do two things differently: + // 1) Calling the updated binary fails due to the xterminal.MakeOutputRaw call in main; skipping this check on Windows. + // 2) We must rename the currently running binary before renaming the new binary + if u.osF() == goosWindows { + err = u.fs.Rename(u.executablePath, updatedCoderBinaryPath+".old") + if err != nil { + return xerrors.Errorf("windows: rename current coder binary: %w", err) + } + err = u.fs.Rename(updatedCoderBinaryPath, u.executablePath) + if err != nil { + return xerrors.Errorf("windows: rename updated coder binary: %w", err) + } + return nil + } + + // validate that we can execute the new binary before overwriting + updatedVersionOutput, err := u.execF(ctx, updatedCoderBinaryPath, "--version") + if err != nil { + return xerrors.Errorf("check version of updated coder binary: %w", err) + } + clog.LogInfo(fmt.Sprintf("updated binary reports %s", bytes.TrimSpace(updatedVersionOutput))) + + if err = u.fs.Rename(updatedCoderBinaryPath, u.executablePath); err != nil { + return xerrors.Errorf("update coder binary in-place: %w", err) + } + + return nil +} + +func getDesiredVersion(httpClient getter, coderURLArg string, versionArg string) (*semver.Version, error) { + var coderURL *url.URL + var desiredVersion *semver.Version + var err error + + if coderURLArg != "" && versionArg != "" { + clog.LogWarn(fmt.Sprintf("ignoring the version reported by %q", coderURLArg), clog.Causef("--version flag was specified explicitly")) + } + + if versionArg != "" { + desiredVersion, err = semver.StrictNewVersion(versionArg) + if err != nil { + return &semver.Version{}, xerrors.Errorf("parse desired version arg: %w", err) + } + return desiredVersion, nil + } + + if coderURLArg == "" { + coderURL, err = getCoderConfigURL() + if err != nil { + return &semver.Version{}, xerrors.Errorf("get coder url: %w", err) + } + } else { + coderURL, err = url.Parse(coderURLArg) + if err != nil { + return &semver.Version{}, xerrors.Errorf("parse coder url arg: %w", err) + } + } + + desiredVersion, err = getAPIVersionUnauthed(httpClient, *coderURL) + if err != nil { + return &semver.Version{}, xerrors.Errorf("query coder version: %w", err) + } + + clog.LogInfo(fmt.Sprintf("Coder instance at %q reports version %s", coderURL.String(), desiredVersion.String())) + + return desiredVersion, nil +} + +func defaultConfirm(label string) (string, error) { + p := promptui.Prompt{IsConfirm: true, Label: label} + return p.Run() +} + +func queryGithubAssetURL(httpClient getter, version *semver.Version, ostype string) (string, error) { + var b bytes.Buffer + fmt.Fprintf(&b, "%d", version.Major()) + fmt.Fprint(&b, ".") + fmt.Fprintf(&b, "%d", version.Minor()) + fmt.Fprint(&b, ".") + fmt.Fprintf(&b, "%d", version.Patch()) + if version.Prerelease() != "" { + fmt.Fprint(&b, "-") + fmt.Fprint(&b, version.Prerelease()) + } + + urlString := fmt.Sprintf("https://api.github.com/repos/cdr/coder-cli/releases/tags/v%s", b.String()) + clog.LogInfo("query github releases", fmt.Sprintf("url: %q", urlString)) + + type asset struct { + BrowserDownloadURL string `json:"browser_download_url"` + Name string `json:"name"` + } + type release struct { + Assets []asset `json:"assets"` + } + var r release + + resp, err := httpClient.Get(urlString) + if err != nil { + return "", xerrors.Errorf("query github release url %s: %w", urlString, err) + } + defer resp.Body.Close() + + err = json.NewDecoder(resp.Body).Decode(&r) + if err != nil { + return "", xerrors.Errorf("unmarshal github releases api response: %w", err) + } + + var assetURLStr string + for _, a := range r.Assets { + if strings.HasPrefix(a.Name, "coder-cli-"+ostype) { + assetURLStr = a.BrowserDownloadURL + } + } + + if assetURLStr == "" { + return "", xerrors.Errorf("could not find release for ostype %s", ostype) + } + + return assetURLStr, nil +} + +func extractFromArchive(path string, archive []byte) ([]byte, error) { + contentType := http.DetectContentType(archive) + switch contentType { + case "application/zip": + return extractFromZip(path, archive) + case "application/x-gzip": + return extractFromTgz(path, archive) + default: + return nil, xerrors.Errorf("unknown archive type: %s", contentType) + } +} + +func extractFromZip(path string, archive []byte) ([]byte, error) { + zipReader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return nil, xerrors.Errorf("failed to open zip archive") + } + + var zf *zip.File + for _, f := range zipReader.File { + if f.Name == path { + zf = f + break + } + } + if zf == nil { + return nil, xerrors.Errorf("could not find path %q in zip archive", path) + } + + rc, err := zf.Open() + if err != nil { + return nil, xerrors.Errorf("failed to extract path %q from archive", path) + } + defer rc.Close() + + var b bytes.Buffer + bw := bufio.NewWriter(&b) + if _, err := io.Copy(bw, rc); err != nil { + return nil, xerrors.Errorf("failed to copy path %q to from archive", path) + } + return b.Bytes(), nil +} + +func extractFromTgz(path string, archive []byte) ([]byte, error) { + zr, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return nil, xerrors.Errorf("failed to gunzip archive") + } + + tr := tar.NewReader(zr) + + var b bytes.Buffer + bw := bufio.NewWriter(&b) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, xerrors.Errorf("failed to read tar archive: %w", err) + } + fi := hdr.FileInfo() + if fi.Name() == path && fi.Mode().IsRegular() { + _, err = io.Copy(bw, tr) + if err != nil { + return nil, xerrors.Errorf("failed to read file %q from archive", fi.Name()) + } + break + } + } + + return b.Bytes(), nil +} + +// getCoderConfigURL reads the currently configured coder URL, returning an empty string if not configured. +func getCoderConfigURL() (*url.URL, error) { + urlString, err := config.URL.Read() + if err != nil { + return nil, err + } + configuredURL, err := url.Parse(strings.TrimSpace(urlString)) + if err != nil { + return nil, err + } + return configuredURL, nil +} + +// XXX: coder.Client requires an API key, but we may not be logged into the coder instance for which we +// want to determine the version. We don't need an API key to hit /api/private/version though. +func getAPIVersionUnauthed(client getter, baseURL url.URL) (*semver.Version, error) { + baseURL.Path = path.Join(baseURL.Path, "/api/private/version") + resp, err := client.Get(baseURL.String()) + if err != nil { + return nil, xerrors.Errorf("get %s: %w", baseURL.String(), err) + } + defer resp.Body.Close() + + ver := struct { + Version string `json:"version"` + }{} + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, xerrors.Errorf("read response body: %w", err) + } + + if err := json.Unmarshal(body, &ver); err != nil { + return nil, xerrors.Errorf("parse version response: %w", err) + } + + version, err := semver.StrictNewVersion(ver.Version) + if err != nil { + return nil, xerrors.Errorf("parsing coder version: %w", err) + } + + return version, nil +} + +// HasFilePathPrefix reports whether the filesystem path s +// begins with the elements in prefix. +// Lifted from github.com/golang/go/blob/master/src/cmd/internal/str/path.go. +func HasFilePathPrefix(s, prefix string) bool { + sv := strings.ToUpper(filepath.VolumeName(s)) + pv := strings.ToUpper(filepath.VolumeName(prefix)) + s = s[len(sv):] + prefix = prefix[len(pv):] + switch { + default: + return false + case sv != pv: + return false + case len(s) == len(prefix): + return s == prefix + case prefix == "": + return true + case len(s) > len(prefix): + if prefix[len(prefix)-1] == filepath.Separator { + return strings.HasPrefix(s, prefix) + } + return s[len(prefix)] == filepath.Separator && s[:len(prefix)] == prefix + } +} + +// defaultExec wraps exec.CommandContext. +func defaultExec(ctx context.Context, cmd string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, cmd, args...).CombinedOutput() +} diff --git a/internal/cmd/update_test.go b/internal/cmd/update_test.go new file mode 100644 index 00000000..c751e2c4 --- /dev/null +++ b/internal/cmd/update_test.go @@ -0,0 +1,545 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "io/fs" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "cdr.dev/slog/sloggers/slogtest/assert" + "github.com/manifoldco/promptui" + "github.com/spf13/afero" + "golang.org/x/xerrors" + + "cdr.dev/coder-cli/pkg/clog" +) + +const ( + fakeExePathLinux = "/home/user/bin/coder" + fakeExePathWindows = `C:\Users\user\bin\coder.exe` + fakeCoderURL = "https://my.cdr.dev" + fakeNewVersion = "1.23.4-rc.5+678-gabcdef-12345678" + fakeOldVersion = "1.22.4-rc.5+678-gabcdef-12345678" + filenameLinux = "coder-cli-linux-amd64.tar.gz" + filenameWindows = "coder-cli-windows.zip" + fakeGithubReleaseURL = "https://api.github.com/repos/cdr/coder-cli/releases/tags/v1.23.4-rc.5" +) + +var ( + apiPrivateVersionURL = fakeCoderURL + apiPrivateVersion + fakeError = xerrors.New("fake error for testing") + fakeNewVersionJSON = fmt.Sprintf(`{"version":%q}`, fakeNewVersion) + fakeOldVersionJSON = fmt.Sprintf(`{"version":%q}`, fakeOldVersion) + fakeAssetURLLinux = "https://github.com/cdr/coder-cli/releases/download/v1.23.4-rc.5/" + filenameLinux + fakeAssetURLWindows = "https://github.com/cdr/coder-cli/releases/download/v1.23.4-rc.5/" + filenameWindows + fakeGithubReleaseJSON = fmt.Sprintf(`{"assets":[{"name":%q,"browser_download_url":%q},{"name":%q,"browser_download_url":%q}]}`, filenameLinux, fakeAssetURLLinux, filenameWindows, fakeAssetURLWindows) +) + +func Test_updater_run(t *testing.T) { + t.Parallel() + + // params holds parameters for each test case + type params struct { + ConfirmF func(string) (string, error) + Ctx context.Context + Execer *fakeExecer + ExecutablePath string + Fakefs afero.Fs + HTTPClient *fakeGetter + OsF func() string + VersionF func() string + } + + // fromParams creates a new updater from params + fromParams := func(p *params) *updater { + return &updater{ + confirmF: p.ConfirmF, + execF: p.Execer.ExecF, + executablePath: p.ExecutablePath, + fs: p.Fakefs, + httpClient: p.HTTPClient, + osF: p.OsF, + versionF: p.VersionF, + } + } + + run := func(t *testing.T, name string, fn func(t *testing.T, p *params)) { + t.Run(name, func(t *testing.T) { + t.Logf("running %s", name) + ctx := context.Background() + fakefs := afero.NewMemMapFs() + execer := newFakeExecer(t) + execer.M["brew --prefix"] = fakeExecerResult{[]byte{}, os.ErrNotExist} + params := ¶ms{ + // This must be overridden inside run() + ConfirmF: func(string) (string, error) { + t.Errorf("unhandled ConfirmF") + t.FailNow() + return "", nil + }, + Execer: execer, + Ctx: ctx, + ExecutablePath: fakeExePathLinux, + Fakefs: fakefs, + HTTPClient: newFakeGetter(t), + // Default to GOOS=linux + OsF: func() string { return goosLinux }, + // This must be overridden inside run() + VersionF: func() string { + t.Errorf("unhandled VersionF") + t.FailNow() + return "" + }, + } + + fn(t, params) + }) + } + + run(t, "update coder - noop", func(t *testing.T, p *params) { + fakeFile(t, p.Fakefs, fakeExePathLinux, 0755, fakeNewVersion) + p.HTTPClient.M[apiPrivateVersionURL] = newFakeGetterResponse([]byte(fakeNewVersionJSON), 200, variadicS(), nil) + p.VersionF = func() string { return fakeNewVersion } + u := fromParams(p) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeNewVersion) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assert.Success(t, "update coder - noop", err) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeNewVersion) + }) + + run(t, "update coder - explicit version specified", func(t *testing.T, p *params) { + fakeFile(t, p.Fakefs, fakeExePathLinux, 0755, fakeOldVersion) + p.HTTPClient.M[apiPrivateVersionURL] = newFakeGetterResponse([]byte(fakeOldVersionJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeGithubReleaseURL] = newFakeGetterResponse([]byte(fakeGithubReleaseJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeAssetURLLinux] = newFakeGetterResponse(fakeValidTgzBytes, 200, variadicS(), nil) + p.VersionF = func() string { return fakeOldVersion } + p.ConfirmF = fakeConfirmYes + p.Execer.M[p.ExecutablePath+".new --version"] = fakeExecerResult{[]byte(fakeNewVersion), nil} + u := fromParams(p) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + err := u.Run(p.Ctx, false, fakeCoderURL, fakeNewVersion) + assert.Success(t, "update coder - explicit version specified", err) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeNewVersion) + }) + + run(t, "update coder - old to new", func(t *testing.T, p *params) { + fakeFile(t, p.Fakefs, fakeExePathLinux, 0755, fakeOldVersion) + p.HTTPClient.M[apiPrivateVersionURL] = newFakeGetterResponse([]byte(fakeNewVersionJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeGithubReleaseURL] = newFakeGetterResponse([]byte(fakeGithubReleaseJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeAssetURLLinux] = newFakeGetterResponse(fakeValidTgzBytes, 200, variadicS(), nil) + p.VersionF = func() string { return fakeOldVersion } + p.ConfirmF = fakeConfirmYes + p.Execer.M[p.ExecutablePath+".new --version"] = fakeExecerResult{[]byte(fakeNewVersion), nil} + u := fromParams(p) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assert.Success(t, "update coder - old to new", err) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeNewVersion) + }) + + run(t, "update coder - old to new - binary renamed", func(t *testing.T, p *params) { + p.ExecutablePath = "/home/user/bin/coder-cli" + fakeFile(t, p.Fakefs, p.ExecutablePath, 0755, fakeOldVersion) + p.HTTPClient.M[apiPrivateVersionURL] = newFakeGetterResponse([]byte(fakeNewVersionJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeGithubReleaseURL] = newFakeGetterResponse([]byte(fakeGithubReleaseJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeAssetURLLinux] = newFakeGetterResponse(fakeValidTgzBytes, 200, variadicS(), nil) + p.VersionF = func() string { return fakeOldVersion } + p.ConfirmF = fakeConfirmYes + p.Execer.M[p.ExecutablePath+".new --version"] = fakeExecerResult{[]byte(fakeNewVersion), nil} + u := fromParams(p) + assertFileContent(t, p.Fakefs, p.ExecutablePath, fakeOldVersion) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assert.Success(t, "update coder - old to new - binary renamed", err) + assertFileContent(t, p.Fakefs, p.ExecutablePath, fakeNewVersion) + }) + + run(t, "update coder - old to new - windows", func(t *testing.T, p *params) { + p.OsF = func() string { return goosWindows } + p.ExecutablePath = fakeExePathWindows + fakeFile(t, p.Fakefs, fakeExePathWindows, 0755, fakeOldVersion) + p.HTTPClient.M[apiPrivateVersionURL] = newFakeGetterResponse([]byte(fakeNewVersionJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeGithubReleaseURL] = newFakeGetterResponse([]byte(fakeGithubReleaseJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeAssetURLWindows] = newFakeGetterResponse(fakeValidZipBytes, 200, variadicS(), nil) + p.VersionF = func() string { return fakeOldVersion } + p.ConfirmF = fakeConfirmYes + p.Execer.M[p.ExecutablePath+".new --version"] = fakeExecerResult{[]byte(fakeNewVersion), nil} + u := fromParams(p) + assertFileContent(t, p.Fakefs, fakeExePathWindows, fakeOldVersion) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assert.Success(t, "update coder - old to new - windows", err) + assertFileContent(t, p.Fakefs, fakeExePathWindows, fakeNewVersion) + }) + + run(t, "update coder - old to new forced", func(t *testing.T, p *params) { + fakeFile(t, p.Fakefs, fakeExePathLinux, 0755, fakeOldVersion) + p.HTTPClient.M[apiPrivateVersionURL] = newFakeGetterResponse([]byte(fakeNewVersionJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeGithubReleaseURL] = newFakeGetterResponse([]byte(fakeGithubReleaseJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeAssetURLLinux] = newFakeGetterResponse(fakeValidTgzBytes, 200, variadicS(), nil) + p.VersionF = func() string { return fakeOldVersion } + p.Execer.M[p.ExecutablePath+".new --version"] = fakeExecerResult{[]byte(fakeNewVersion), nil} + u := fromParams(p) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + err := u.Run(p.Ctx, true, fakeCoderURL, "") + assert.Success(t, "update coder - old to new forced", err) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeNewVersion) + }) + + run(t, "update coder - user cancelled", func(t *testing.T, p *params) { + fakeFile(t, p.Fakefs, fakeExePathLinux, 0755, fakeOldVersion) + p.HTTPClient.M[apiPrivateVersionURL] = newFakeGetterResponse([]byte(fakeNewVersionJSON), 200, variadicS(), nil) + p.VersionF = func() string { return fakeOldVersion } + p.ConfirmF = fakeConfirmNo + u := fromParams(p) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assertCLIError(t, "update coder - user cancelled", err, "user cancelled operation", "") + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + }) + + run(t, "update coder - cannot stat", func(t *testing.T, p *params) { + u := fromParams(p) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assertCLIError(t, "update coder - cannot stat", err, "cannot stat current binary", os.ErrNotExist.Error()) + }) + + run(t, "update coder - no permission", func(t *testing.T, p *params) { + fakeFile(t, p.Fakefs, fakeExePathLinux, 0400, fakeOldVersion) + u := fromParams(p) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assertCLIError(t, "update coder - no permission", err, "missing write permission", "") + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + }) + + run(t, "update coder - invalid version arg", func(t *testing.T, p *params) { + fakeFile(t, p.Fakefs, fakeExePathLinux, 0755, fakeOldVersion) + p.VersionF = func() string { return fakeOldVersion } + u := fromParams(p) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + err := u.Run(p.Ctx, false, fakeCoderURL, "Invalid Semantic Version") + assertCLIError(t, "update coder - invalid version arg", err, "failed to determine desired version of coder", "Invalid Semantic Version") + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + }) + + run(t, "update coder - invalid url", func(t *testing.T, p *params) { + fakeFile(t, p.Fakefs, fakeExePathLinux, 0755, fakeOldVersion) + p.VersionF = func() string { return fakeOldVersion } + u := fromParams(p) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + err := u.Run(p.Ctx, false, "h$$p://invalid.url", "") + assertCLIError(t, "update coder - invalid url", err, "failed to determine desired version of coder", "first path segment in URL cannot contain colon") + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + }) + + run(t, "update coder - fetch api version failure", func(t *testing.T, p *params) { + fakeFile(t, p.Fakefs, fakeExePathLinux, 0755, fakeOldVersion) + p.HTTPClient.M[apiPrivateVersionURL] = newFakeGetterResponse([]byte{}, 401, variadicS(), fakeError) + p.VersionF = func() string { return fakeOldVersion } + u := fromParams(p) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assertCLIError(t, "update coder - fetch api version failure", err, "failed to determine desired version of coder", fakeError.Error()) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + }) + + run(t, "update coder - failed to query github releases", func(t *testing.T, p *params) { + fakeFile(t, p.Fakefs, fakeExePathLinux, 0755, fakeOldVersion) + p.HTTPClient.M[apiPrivateVersionURL] = newFakeGetterResponse([]byte(fakeNewVersionJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeGithubReleaseURL] = newFakeGetterResponse([]byte{}, 0, variadicS(), fakeError) + p.VersionF = func() string { return fakeOldVersion } + p.ConfirmF = fakeConfirmYes + u := fromParams(p) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assertCLIError(t, "update coder - failed to query github releases", err, "failed to query github assets", fakeError.Error()) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + }) + + run(t, "update coder - failed to fetch URL", func(t *testing.T, p *params) { + fakeFile(t, p.Fakefs, fakeExePathLinux, 0755, fakeOldVersion) + p.HTTPClient.M[apiPrivateVersionURL] = newFakeGetterResponse([]byte(fakeNewVersionJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeGithubReleaseURL] = newFakeGetterResponse([]byte(fakeGithubReleaseJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeAssetURLLinux] = newFakeGetterResponse([]byte{}, 0, variadicS(), fakeError) + p.VersionF = func() string { return fakeOldVersion } + p.ConfirmF = fakeConfirmYes + u := fromParams(p) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assertCLIError(t, "update coder - failed to fetch URL", err, "failed to fetch URL", fakeError.Error()) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + }) + + run(t, "update coder - release URL 404", func(t *testing.T, p *params) { + fakeFile(t, p.Fakefs, fakeExePathLinux, 0755, fakeOldVersion) + p.HTTPClient.M[apiPrivateVersionURL] = newFakeGetterResponse([]byte(fakeNewVersionJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeGithubReleaseURL] = newFakeGetterResponse([]byte(fakeGithubReleaseJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeAssetURLLinux] = newFakeGetterResponse([]byte{}, 404, variadicS(), nil) + p.VersionF = func() string { return fakeOldVersion } + p.ConfirmF = fakeConfirmYes + u := fromParams(p) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assertCLIError(t, "update coder - release URL 404", err, "failed to fetch release", "status code 404") + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + }) + + run(t, "update coder - invalid tgz archive", func(t *testing.T, p *params) { + fakeFile(t, p.Fakefs, fakeExePathLinux, 0755, fakeOldVersion) + p.HTTPClient.M[apiPrivateVersionURL] = newFakeGetterResponse([]byte(fakeNewVersionJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeGithubReleaseURL] = newFakeGetterResponse([]byte(fakeGithubReleaseJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeAssetURLLinux] = newFakeGetterResponse([]byte{}, 200, variadicS(), nil) + p.VersionF = func() string { return fakeOldVersion } + p.ConfirmF = fakeConfirmYes + u := fromParams(p) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assertCLIError(t, "update coder - invalid tgz archive", err, "failed to extract coder binary from archive", "unknown archive type") + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + }) + + run(t, "update coder - invalid zip archive", func(t *testing.T, p *params) { + p.OsF = func() string { return goosWindows } + p.ExecutablePath = fakeExePathWindows + fakeFile(t, p.Fakefs, fakeExePathWindows, 0755, fakeOldVersion) + p.HTTPClient.M[apiPrivateVersionURL] = newFakeGetterResponse([]byte(fakeNewVersionJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeGithubReleaseURL] = newFakeGetterResponse([]byte(fakeGithubReleaseJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeAssetURLWindows] = newFakeGetterResponse([]byte{}, 200, variadicS(), nil) + p.VersionF = func() string { return fakeOldVersion } + p.ConfirmF = fakeConfirmYes + u := fromParams(p) + assertFileContent(t, p.Fakefs, p.ExecutablePath, fakeOldVersion) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assertCLIError(t, "update coder - invalid zip archive", err, "failed to extract coder binary from archive", "unknown archive type") + assertFileContent(t, p.Fakefs, p.ExecutablePath, fakeOldVersion) + }) + + run(t, "update coder - read-only fs", func(t *testing.T, p *params) { + rwfs := p.Fakefs + p.Fakefs = afero.NewReadOnlyFs(rwfs) + fakeFile(t, rwfs, fakeExePathLinux, 0755, fakeOldVersion) + p.HTTPClient.M[apiPrivateVersionURL] = newFakeGetterResponse([]byte(fakeNewVersionJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeGithubReleaseURL] = newFakeGetterResponse([]byte(fakeGithubReleaseJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeAssetURLLinux] = newFakeGetterResponse(fakeValidTgzBytes, 200, variadicS(), nil) + p.VersionF = func() string { return fakeOldVersion } + p.ConfirmF = fakeConfirmYes + u := fromParams(p) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assertCLIError(t, "update coder - read-only fs", err, "failed to create file", "") + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + }) + + run(t, "update coder - cannot exec new binary", func(t *testing.T, p *params) { + fakeFile(t, p.Fakefs, fakeExePathLinux, 0755, fakeOldVersion) + p.HTTPClient.M[apiPrivateVersionURL] = newFakeGetterResponse([]byte(fakeNewVersionJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeGithubReleaseURL] = newFakeGetterResponse([]byte(fakeGithubReleaseJSON), 200, variadicS(), nil) + p.HTTPClient.M[fakeAssetURLLinux] = newFakeGetterResponse(fakeValidTgzBytes, 200, variadicS(), nil) + p.VersionF = func() string { return fakeOldVersion } + p.ConfirmF = fakeConfirmYes + p.Execer.M[p.ExecutablePath+".new --version"] = fakeExecerResult{nil, fakeError} + u := fromParams(p) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assertCLIError(t, "update coder - cannot exec new binary", err, "failed to update coder binary", fakeError.Error()) + assertFileContent(t, p.Fakefs, fakeExePathLinux, fakeOldVersion) + }) + + if runtime.GOOS == goosWindows { + run(t, "update coder - path blocklist - windows", func(t *testing.T, p *params) { + p.ExecutablePath = `C:\Windows\system32\coder.exe` + u := fromParams(p) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assertCLIError(t, "update coder - path blocklist - windows", err, "cowardly refusing to update coder binary", "blocklisted prefix") + }) + } else { + run(t, "update coder - path blocklist - coder assets dir", func(t *testing.T, p *params) { + p.ExecutablePath = `/var/tmp/coder/coder` + u := fromParams(p) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assertCLIError(t, "update coder - path blocklist - windows", err, "cowardly refusing to update coder binary", "blocklisted prefix") + }) + run(t, "update coder - path blocklist - old homebrew prefix", func(t *testing.T, p *params) { + p.Execer.M["brew --prefix"] = fakeExecerResult{[]byte("/usr/local"), nil} + p.ExecutablePath = `/usr/local/bin/coder` + u := fromParams(p) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assertCLIError(t, "update coder - path blocklist - old homebrew prefix", err, "cowardly refusing to update coder binary", "blocklisted prefix") + }) + run(t, "update coder - path blocklist - new homebrew prefix", func(t *testing.T, p *params) { + p.Execer.M["brew --prefix"] = fakeExecerResult{[]byte("/opt/homebrew"), nil} + p.ExecutablePath = `/opt/homebrew/bin/coder` + u := fromParams(p) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assertCLIError(t, "update coder - path blocklist - new homebrew prefix", err, "cowardly refusing to update coder binary", "blocklisted prefix") + }) + run(t, "update coder - path blocklist - linuxbrew", func(t *testing.T, p *params) { + p.Execer.M["brew --prefix"] = fakeExecerResult{[]byte("/home/user/.linuxbrew"), nil} + p.ExecutablePath = `/home/user/.linuxbrew/bin/coder` + u := fromParams(p) + err := u.Run(p.Ctx, false, fakeCoderURL, "") + assertCLIError(t, "update coder - path blocklist - linuxbrew", err, "cowardly refusing to update coder binary", "blocklisted prefix") + }) + } +} + +// fakeGetter mocks HTTP requests. +type fakeGetter struct { + M map[string]*fakeGetterResponse + T *testing.T +} + +func newFakeGetter(t *testing.T) *fakeGetter { + return &fakeGetter{ + M: make(map[string]*fakeGetterResponse), + T: t, + } +} + +// Get returns the configured response for url. If no response configured, test fails immediately. +func (f *fakeGetter) Get(url string) (*http.Response, error) { + f.T.Helper() + val, ok := f.M[url] + if !ok { + f.T.Errorf("unhandled url: %s", url) + f.T.FailNow() + return nil, nil // this will never happen + } + return val.Resp, val.Err +} + +type fakeGetterResponse struct { + Resp *http.Response + Err error +} + +// newFakeGetterResponse is a convenience function for mocking HTTP requests. +func newFakeGetterResponse(body []byte, code int, headers []string, err error) *fakeGetterResponse { + resp := &http.Response{} + resp.Body = ioutil.NopCloser(bytes.NewReader(body)) + resp.StatusCode = code + resp.Header = http.Header{} + + for _, e := range headers { + parts := strings.Split(e, ":") + k := strings.ToLower(strings.TrimSpace(parts[0])) + v := strings.ToLower(strings.TrimSpace(strings.Join(parts[1:], ":"))) + resp.Header.Set(k, v) + } + + return &fakeGetterResponse{ + Resp: resp, + Err: err, + } +} + +func variadicS(s ...string) []string { + return s +} + +func fakeConfirmYes(_ string) (string, error) { + return "y", nil +} + +func fakeConfirmNo(_ string) (string, error) { + return "", promptui.ErrAbort +} + +func fakeFile(t *testing.T, fs afero.Fs, name string, perm fs.FileMode, content string) { + t.Helper() + err := fs.MkdirAll(filepath.Dir(name), 0750) + if err != nil { + panic(err) + } + f, err := fs.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, perm) + if err != nil { + panic(err) + } + defer f.Close() + + _, err = f.Write([]byte(content)) + if err != nil { + panic(err) + } +} + +func assertFileContent(t *testing.T, fs afero.Fs, name string, content string) { + t.Helper() + f, err := fs.OpenFile(name, os.O_RDONLY, 0) + assert.Success(t, "open file "+name, err) + defer f.Close() + + b, err := ioutil.ReadAll(f) + assert.Success(t, "read file "+name, err) + + assert.Equal(t, "assert content equal", content, string(b)) +} + +func assertCLIError(t *testing.T, name string, err error, expectedHeader, expectedLines string) { + t.Helper() + cliError, ok := err.(clog.CLIError) + if !ok { + t.Errorf("%s: assert cli error: %+v is not a cli error", name, err) + } + + if !strings.Contains(err.Error(), expectedHeader) { + t.Errorf("%s: assert cli error: expected header %q to contain %q", name, err.Error(), expectedHeader) + } + + if expectedLines == "" { + return + } + + fullLines := strings.Join(cliError.Lines, "\n") + if !strings.Contains(fullLines, expectedLines) { + t.Errorf("%s: assert cli error: expected %q to contain %q", name, fullLines, expectedLines) + } +} + +// this is a valid tgz archive containing a single file named 'coder' with permissions 0751 +// containing the string "1.23.4-rc.5+678-gabcdef-12345678". +var fakeValidTgzBytes, _ = base64.StdEncoding.DecodeString(`H4sIAAAAAAAAA+3QsQ4CIRCEYR6F3oC7wIqvc3KnpQnq+3tGCwsTK3LN/zWTTDWZuG/XeeluJFlV +s1dqNfnOtyJOi4qllHOuTlSTqPMydNXH43afuvfu3w3jb9qExpRjCb1F2x3qMVymU5uXc9CUi63F +1vsAAAAAAAAAAAAAAAAAAL89AYuL424AKAAA`) + +// this is a valid zip archive containing a single file named 'coder.exe' with permissions 0751 +// containing the string "1.23.4-rc.5+678-gabcdef-12345678". +var fakeValidZipBytes, _ = base64.StdEncoding.DecodeString(`UEsDBAoAAAAAAAtfDVNCHNDCIAAAACAAAAAJABwAY29kZXIuZXhlVVQJAAPmXRZh/10WYXV4CwAB +BOgDAAAE6AMAADEuMjMuNC1yYy41KzY3OC1nYWJjZGVmLTEyMzQ1Njc4UEsBAh4DCgAAAAAAC18N +U0Ic0MIgAAAAIAAAAAkAGAAAAAAAAQAAAO2BAAAAAGNvZGVyLmV4ZVVUBQAD5l0WYXV4CwABBOgD +AAAE6AMAAFBLBQYAAAAAAQABAE8AAABjAAAAAAA=`) + +type fakeExecer struct { + M map[string]fakeExecerResult + T *testing.T +} + +func (f *fakeExecer) ExecF(_ context.Context, cmd string, args ...string) ([]byte, error) { + cmdAndArgs := strings.Join(append([]string{cmd}, args...), " ") + val, ok := f.M[cmdAndArgs] + if !ok { + f.T.Errorf("unhandled cmd %q", cmd) + f.T.FailNow() + return nil, nil // will never happen + } + return val.Output, val.Err +} + +func newFakeExecer(t *testing.T) *fakeExecer { + return &fakeExecer{ + M: make(map[string]fakeExecerResult), + T: t, + } +} + +type fakeExecerResult struct { + Output []byte + Err error +} diff --git a/internal/version/version.go b/internal/version/version.go index ce1d5de9..8873f158 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,5 +1,5 @@ // Package version contains the compile-time injected version string and -// related utiliy methods. +// related utility methods. package version import (