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 ( pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy