diff --git a/CHANGELOG.md b/CHANGELOG.md index 4426132bc..9309a1dbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - feat(kvstoreentry/delete): Add support for multiple-key deletion using a key prefix. ([#XXX](https://github.com/fastly/cli/pull/XXX)) - build(dockerfile-go): add Go Dockerfile alongside the existing Node and Rust ones ([#XXX](https://github.com/fastly/cli/pull/1828)) - feat(compute/deploy): Support 'contentguard' configuration on 'bot_management' product under \[setup.products] ([#1827](https://github.com/fastly/cli/pull/1827)) +- feat(compute): add `install-viceroy` command to pre-install the Viceroy binary ([#1833](https://github.com/fastly/cli/pull/1833)) ### Dependencies: - build(deps): `github.com/nwaples/rardecode/v2` from 2.2.3 to 2.2.5 ([#1825](https://github.com/fastly/cli/pull/1825)) diff --git a/Dockerfile-go b/Dockerfile-go index 3a6affbef..44299c140 100644 --- a/Dockerfile-go +++ b/Dockerfile-go @@ -13,6 +13,13 @@ RUN apt-get update && apt-get install -y curl jq && apt-get -y clean && rm -rf / USER fastly +# Pre-install Viceroy so the image is self-contained (no download at runtime). +# Must run as the `fastly` user, since Viceroy installs into that user's config +# directory (where `compute serve` later looks for it). +# +# TODO: Enable once https://github.com/fastly/cli/pull/1833 was released +# RUN fastly compute install-viceroy + WORKDIR /app ENTRYPOINT ["/usr/bin/fastly"] CMD ["--help"] diff --git a/Dockerfile-node b/Dockerfile-node index 80d682bb5..dbd16d184 100644 --- a/Dockerfile-node +++ b/Dockerfile-node @@ -13,6 +13,13 @@ RUN apt-get update && apt-get install -y curl jq && apt-get -y clean && rm -rf / USER fastly +# Pre-install Viceroy so the image is self-contained (no download at runtime). +# Must run as the `fastly` user, since Viceroy installs into that user's config +# directory (where `compute serve` later looks for it). +# +# TODO: Enable once https://github.com/fastly/cli/pull/1833 was released +# RUN fastly compute install-viceroy + WORKDIR /app ENTRYPOINT ["/usr/bin/fastly"] CMD ["--help"] diff --git a/Dockerfile-rust b/Dockerfile-rust index a9771e63e..1e7a35816 100644 --- a/Dockerfile-rust +++ b/Dockerfile-rust @@ -16,6 +16,13 @@ RUN rustup target add wasm32-wasip1 \ USER fastly +# Pre-install Viceroy so the image is self-contained (no download at runtime). +# Must run as the `fastly` user, since Viceroy installs into that user's config +# directory (where `compute serve` later looks for it). +# +# TODO: Enable once https://github.com/fastly/cli/pull/1833 was released +# RUN fastly compute install-viceroy + WORKDIR /app ENTRYPOINT ["/usr/bin/fastly"] CMD ["--help"] diff --git a/pkg/app/run.go b/pkg/app/run.go index 4f869362b..254bb0cdf 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -753,7 +753,7 @@ func commandRequiresToken(command argparser.Command) bool { return text.IsFastlyID(initCmd.CloneFrom) } return false - case "compute build", "compute hash-files", "compute metadata", "compute pack", "compute serve", "compute validate": + case "compute build", "compute hash-files", "compute install-viceroy", "compute metadata", "compute pack", "compute serve", "compute validate": return false } commandName = strings.Split(commandName, " ")[0] diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index 1caf7f22e..72feac97f 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -254,6 +254,7 @@ func Define( // nolint:revive // function-length computeDeploy := compute.NewDeployCommand(computeCmdRoot.CmdClause, data) computeHashFiles := compute.NewHashFilesCommand(computeCmdRoot.CmdClause, data, computeBuild) computeInit := compute.NewInitCommand(computeCmdRoot.CmdClause, data) + computeInstallViceroy := compute.NewInstallCommand(computeCmdRoot.CmdClause, data) computeMetadata := compute.NewMetadataCommand(computeCmdRoot.CmdClause, data) computePack := compute.NewPackCommand(computeCmdRoot.CmdClause, data) computePublish := compute.NewPublishCommand(computeCmdRoot.CmdClause, data, computeBuild, computeDeploy) @@ -1141,6 +1142,7 @@ func Define( // nolint:revive // function-length computeDeploy, computeHashFiles, computeInit, + computeInstallViceroy, computeMetadata, computePack, computePublish, diff --git a/pkg/commands/compute/install.go b/pkg/commands/compute/install.go new file mode 100644 index 000000000..a98eeb6e7 --- /dev/null +++ b/pkg/commands/compute/install.go @@ -0,0 +1,58 @@ +package compute + +import ( + "errors" + "io" + "runtime" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// InstallCommand installs the Viceroy binary that `compute serve` otherwise +// downloads on first use. It shares the install logic via viceroyInstaller +// (see viceroy.go), and is intended to pre-warm container images so that +// `compute serve` doesn't need network access at runtime. +type InstallCommand struct { + argparser.Base +} + +// NewInstallCommand returns a usable command registered under the parent. +func NewInstallCommand(parent argparser.Registerer, g *global.Data) *InstallCommand { + var c InstallCommand + c.Globals = g + c.CmdClause = parent.Command("install-viceroy", "Download and install the Viceroy binary used by `compute serve`") + return &c +} + +// Exec implements the command interface. +func (c *InstallCommand) Exec(_ io.Reader, out io.Writer) error { + if runtime.GOARCH == "386" { + return fsterr.RemediationError{ + Inner: errors.New("this command doesn't support the '386' architecture"), + Remediation: "Although the Fastly CLI supports '386', https://github.com/fastly/Viceroy does not.", + } + } + + spinner, err := text.NewSpinner(out) + if err != nil { + return err + } + + // The versioner is already seeded from the manifest's viceroy_version at + // startup, so a pinned version is honored when run inside a project. The + // manifest path is only used in messages, hence the default filename. + bin, err := viceroyInstaller{ + Globals: c.Globals, + Versioner: c.Globals.Versioners.Viceroy, + }.get(spinner, out, manifest.Filename) + if err != nil { + return err + } + + text.Success(out, "Installed Viceroy to: %s", bin) + return nil +} diff --git a/pkg/commands/compute/install_test.go b/pkg/commands/compute/install_test.go new file mode 100644 index 000000000..ef613fe81 --- /dev/null +++ b/pkg/commands/compute/install_test.go @@ -0,0 +1,106 @@ +package compute_test + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/compute" + "github.com/fastly/cli/pkg/config" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/github" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +// TestInstallViceroy validates that `compute install-viceroy` installs Viceroy +// to the appropriate directory using the same install path as `compute serve`. +// +// As with TestGetViceroy, there isn't an executable binary in the test +// environment, so the ` --version` subprocess call errors and the +// installer downloads the (mocked) latest release, which `os.Rename()` then +// moves into the install directory. +func TestInstallViceroy(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + viceroyBinName := "foo" + installDirName := "install" + + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Dirs: []string{ + installDirName, + }, + Write: []testutil.FileIO{ + {Src: "...", Dst: viceroyBinName}, + + // NOTE: Created so the in-memory config can be written back to disk + // without failing because no such file existed. + {Src: "", Dst: config.FileName}, + }, + }) + installDir := filepath.Join(rootdir, installDirName) + binPath := filepath.Join(rootdir, viceroyBinName) + configPath := filepath.Join(rootdir, config.FileName) + defer os.RemoveAll(rootdir) + + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(wd) + }() + + github.InstallDir = installDir + + var out bytes.Buffer + + av := mock.AssetVersioner{ + AssetVersion: "1.2.3", + BinaryFilename: viceroyBinName, + DownloadOK: true, + DownloadedFile: binPath, + } + + var file config.File + + // NOTE: We purposefully provide a nonsensical path, which we expect to fail, + // but the function call should fallback to using the stubbed static config. + err = file.Read("example", strings.NewReader("yes"), &out, fsterr.MockLog{}, false) + if err != nil { + t.Fatal(err) + } + + cmd := &compute.InstallCommand{ + Base: argparser.Base{ + Globals: &global.Data{ + Config: file, + ConfigPath: configPath, + ErrLog: fsterr.MockLog{}, + Versioners: global.Versioners{ + Viceroy: av, + }, + }, + }, + } + if err := cmd.Exec(nil, &out); err != nil { + t.Fatal(err) + } + + if !strings.Contains(out.String(), "Fetching Viceroy release: ") { + t.Fatalf("expected Viceroy to be downloaded successfully") + } + + movedPath := filepath.Join(installDir, viceroyBinName) + + if _, err := os.Stat(movedPath); err != nil { + t.Fatalf("binary was not moved to the install directory: %s", err) + } +} diff --git a/pkg/commands/compute/serve.go b/pkg/commands/compute/serve.go index 7229c9fca..353f20b31 100644 --- a/pkg/commands/compute/serve.go +++ b/pkg/commands/compute/serve.go @@ -26,17 +26,14 @@ import ( "time" "github.com/bep/debounce" - "github.com/blang/semver" "github.com/fatih/color" "github.com/fsnotify/fsnotify" "github.com/mitchellh/go-ps" ignore "github.com/sabhiram/go-gitignore" "github.com/fastly/cli/pkg/argparser" - "github.com/fastly/cli/pkg/check" fsterr "github.com/fastly/cli/pkg/errors" fstexec "github.com/fastly/cli/pkg/exec" - "github.com/fastly/cli/pkg/filesystem" "github.com/fastly/cli/pkg/github" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/manifest" @@ -44,11 +41,6 @@ import ( "github.com/fastly/cli/pkg/text" ) -var viceroyError = fsterr.RemediationError{ - Inner: fmt.Errorf("a Viceroy version was not found"), - Remediation: fsterr.BugRemediation, -} - // ServeCommand produces and runs an artifact from files on the local disk. type ServeCommand struct { argparser.Base @@ -355,236 +347,15 @@ func (c *ServeCommand) setBackendsWithDefaultOverrideHostIfMissing(out io.Writer // // In the case of a network failure we fallback to the latest installed version of the // Viceroy binary as long as one is installed and has the correct permissions. -func (c *ServeCommand) GetViceroy(spinner text.Spinner, out io.Writer, manifestPath string) (bin string, err error) { - if c.ViceroyBinPath != "" { - if c.Globals.Verbose() { - text.Info(out, "Using user provided install of Viceroy via --viceroy-path flag: %s\n\n", c.ViceroyBinPath) - } - return filepath.Abs(c.ViceroyBinPath) - } - - // Allows a user to use a version of Viceroy that is installed in the $PATH. - if usePath := os.Getenv("FASTLY_VICEROY_USE_PATH"); checkViceroyEnvVar(usePath) { - path, err := exec.LookPath("viceroy") - if err != nil { - return "", fmt.Errorf("failed to lookup viceroy binary in user $PATH (user has set $FASTLY_VICEROY_USE_PATH): %w", err) - } - if c.Globals.Verbose() { - text.Info(out, "Using user provided install of Viceroy via $PATH lookup: %s\n\n", path) - } - return filepath.Abs(path) - } - - bin = filepath.Join(github.InstallDir, c.ViceroyVersioner.BinaryName()) - - // NOTE: When checking if Viceroy is installed we don't use - // exec.LookPath("viceroy") because PATH is unreliable across OS platforms, - // but also we actually install Viceroy in the same location as the - // application configuration, which means it wouldn't be found looking up by - // the PATH env var. We could pass the path for the application configuration - // into exec.LookPath() but it's simpler to just execute the binary. - // - // gosec flagged this: - // G204 (CWE-78): Subprocess launched with variable - // Disabling as the variables come from trusted sources. - /* #nosec */ - // nosemgrep - command := exec.Command(bin, "--version") - - var installedVersion string - - stdoutStderr, err := command.CombinedOutput() - if err != nil { - c.Globals.ErrLog.Add(err) - } else { - // Check the version output has the expected format: `viceroy 0.1.0` - installedVersion = strings.TrimSpace(string(stdoutStderr)) - segs := strings.Split(installedVersion, " ") - if len(segs) < 2 { - return bin, viceroyError - } - installedVersion = segs[1] - } - - // If the user hasn't explicitly set a Viceroy version, then we'll use - // whatever the latest version is. - versionToInstall := "latest" - if v := c.ViceroyVersioner.RequestedVersion(); v != "" { - versionToInstall = v - - if _, err := semver.Parse(versionToInstall); err != nil { - return bin, fsterr.RemediationError{ - Inner: fmt.Errorf("failed to parse configured version as a semver: %w", err), - Remediation: fmt.Sprintf("Ensure the %s `viceroy_version` value '%s' (under the [local_server] section) is a valid semver (https://semver.org/), e.g. `0.1.0`)", manifestPath, versionToInstall), - } - } - } - - err = c.InstallViceroy(installedVersion, versionToInstall, manifestPath, bin, spinner) - if err != nil { - c.Globals.ErrLog.Add(err) - return bin, err - } - - err = github.SetBinPerms(bin) - if err != nil { - c.Globals.ErrLog.Add(err) - return bin, err - } - return bin, nil -} - -// checkViceroyEnvVar indicates if the CLI should use a Viceroy binary exposed -// on the user's $PATH. -func checkViceroyEnvVar(value string) bool { - switch strings.ToUpper(value) { - case "1", "TRUE": - return true - } - return false -} - -// InstallViceroy downloads the binary from GitHub. // -// The logic flow is as follows: -// -// 1. Check if version to install is "latest" -// 2. If so, check the latest release matches the installed version. -// 3. If not latest, check the installed version matches the expected version. -func (c *ServeCommand) InstallViceroy( - installedVersion, versionToInstall, manifestPath, bin string, - spinner text.Spinner, -) error { - var ( - err error - msg, tmpBin string - ) - - switch { - case installedVersion == "": // Viceroy not installed - if c.Globals.Verbose() { - text.Info(c.Globals.Output, "Viceroy is not already installed, so we will install the %s version.\n\n", versionToInstall) - } - err = spinner.Start() - if err != nil { - return err - } - msg = fmt.Sprintf("Fetching Viceroy release: %s", versionToInstall) - spinner.Message(msg + "...") - - if versionToInstall == "latest" { - tmpBin, err = c.ViceroyVersioner.DownloadLatest() - } else { - tmpBin, err = c.ViceroyVersioner.DownloadVersion(versionToInstall) - } - case versionToInstall != "latest": - if installedVersion == versionToInstall { - if c.Globals.Verbose() { - text.Info(c.Globals.Output, "Viceroy is already installed, and the installed version matches the required version (%s) in the %s file.\n\n", versionToInstall, manifestPath) - } - return nil - } - if c.Globals.Verbose() { - text.Info(c.Globals.Output, "Viceroy is already installed, but the installed version (%s) doesn't match the required version (%s) specified in the %s file.\n\n", installedVersion, versionToInstall, manifestPath) - } - - err = spinner.Start() - if err != nil { - return err - } - msg = fmt.Sprintf("Fetching Viceroy release: %s", versionToInstall) - spinner.Message(msg + "...") - - tmpBin, err = c.ViceroyVersioner.DownloadVersion(versionToInstall) - case versionToInstall == "latest": - // Viceroy is already installed, so we check if the installed version matches the latest. - // But we'll skip that check if the TTL for the Viceroy LastChecked hasn't expired. - - stale := check.Stale(c.Globals.Config.Viceroy.LastChecked, c.Globals.Config.Viceroy.TTL) - if !stale && !c.ForceCheckViceroyLatest { - if c.Globals.Verbose() { - text.Info(c.Globals.Output, "Viceroy is installed but the CLI config (`fastly config`) shows the TTL, checking for a newer version, hasn't expired. To force a refresh, re-run the command with the `--viceroy-check` flag.\n\n") - } - return nil - } - - // IMPORTANT: We declare separately so to shadow `err` from parent scope. - var latestVersion string - - // NOTE: We won't stop the user because although we can't request the latest - // version of the tool, the user may have a local version already installed. - err = spinner.Process("Checking latest Viceroy release", func(_ *text.SpinnerWrapper) error { - latestVersion, err = c.ViceroyVersioner.LatestVersion() - if err != nil { - return fsterr.RemediationError{ - Inner: fmt.Errorf("error fetching latest version: %w", err), - Remediation: fsterr.NetworkRemediation, - } - } - return nil - }) - if err != nil { - return nil // short-circuit the rest of this function - } - - viceroyConfig := c.Globals.Config.Viceroy - viceroyConfig.LatestVersion = latestVersion - viceroyConfig.LastChecked = time.Now().Format(time.RFC3339) - - // Before attempting to write the config data back to disk we need to - // ensure we reassign the modified struct which is a copy (not reference). - c.Globals.Config.Viceroy = viceroyConfig - - err = c.Globals.Config.Write(c.Globals.ConfigPath) - if err != nil { - return err - } - - if c.Globals.Verbose() { - text.Info(c.Globals.Output, "\nThe CLI config (`fastly config`) has been updated with the latest Viceroy version: %s\n\n", latestVersion) - } - - if installedVersion != "" && installedVersion == latestVersion { - return nil - } - - err = spinner.Start() - if err != nil { - return err - } - msg = fmt.Sprintf("Fetching Viceroy release: %s", versionToInstall) - spinner.Message(msg + "...") - - tmpBin, err = c.ViceroyVersioner.DownloadLatest() - } - - // NOTE: The above `switch` needs to shadow the function-level `err` variable. - if err != nil { - err = fmt.Errorf("error downloading Viceroy release: %w", err) - spinner.StopFailMessage(msg) - spinErr := spinner.StopFail() - if spinErr != nil { - return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) - } - return err - } - defer os.RemoveAll(tmpBin) - - if err := os.Rename(tmpBin, bin); err != nil { - err = fmt.Errorf("failed to rename/move file: %w", err) - if copyErr := filesystem.CopyFile(tmpBin, bin); copyErr != nil { - err = fmt.Errorf("failed to copy file: %w (original error: %w)", copyErr, err) - spinner.StopFailMessage(msg) - spinErr := spinner.StopFail() - if spinErr != nil { - return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) - } - return err - } - } - - spinner.StopMessage(msg) - return spinner.Stop() +// The install logic is shared with `compute install-viceroy` (see viceroy.go). +func (c *ServeCommand) GetViceroy(spinner text.Spinner, out io.Writer, manifestPath string) (bin string, err error) { + return viceroyInstaller{ + Globals: c.Globals, + Versioner: c.ViceroyVersioner, + BinPath: c.ViceroyBinPath, + ForceCheckLatest: c.ForceCheckViceroyLatest, + }.get(spinner, out, manifestPath) } // GetPushpinProxyPort returns the port to run the Pushpin proxy. diff --git a/pkg/commands/compute/viceroy.go b/pkg/commands/compute/viceroy.go new file mode 100644 index 000000000..e2cd8f0bc --- /dev/null +++ b/pkg/commands/compute/viceroy.go @@ -0,0 +1,276 @@ +package compute + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/blang/semver" + + "github.com/fastly/cli/pkg/check" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/filesystem" + "github.com/fastly/cli/pkg/github" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +var viceroyError = fsterr.RemediationError{ + Inner: fmt.Errorf("a Viceroy version was not found"), + Remediation: fsterr.BugRemediation, +} + +// viceroyInstaller installs/updates the Viceroy binary. It is the shared +// implementation used by both `compute serve` and `compute install-viceroy`. +type viceroyInstaller struct { + Globals *global.Data + Versioner github.AssetVersioner + // BinPath is the user provided binary path (--viceroy-path). Empty if unset. + BinPath string + // ForceCheckLatest forces a check for a newer release (--viceroy-check). + ForceCheckLatest bool +} + +// get returns the path to the installed Viceroy binary. +// +// If Viceroy is installed we either update it or pin it to the version defined +// in the fastly.toml [viceroy.viceroy_version]. Otherwise, if not installed, we +// install it in the same directory as the application configuration data. +// +// In the case of a network failure we fallback to the latest installed version of the +// Viceroy binary as long as one is installed and has the correct permissions. +func (vi viceroyInstaller) get(spinner text.Spinner, out io.Writer, manifestPath string) (bin string, err error) { + if vi.BinPath != "" { + if vi.Globals.Verbose() { + text.Info(out, "Using user provided install of Viceroy via --viceroy-path flag: %s\n\n", vi.BinPath) + } + return filepath.Abs(vi.BinPath) + } + + // Allows a user to use a version of Viceroy that is installed in the $PATH. + if usePath := os.Getenv("FASTLY_VICEROY_USE_PATH"); checkViceroyEnvVar(usePath) { + path, err := exec.LookPath("viceroy") + if err != nil { + return "", fmt.Errorf("failed to lookup viceroy binary in user $PATH (user has set $FASTLY_VICEROY_USE_PATH): %w", err) + } + if vi.Globals.Verbose() { + text.Info(out, "Using user provided install of Viceroy via $PATH lookup: %s\n\n", path) + } + return filepath.Abs(path) + } + + bin = filepath.Join(github.InstallDir, vi.Versioner.BinaryName()) + + // NOTE: When checking if Viceroy is installed we don't use + // exec.LookPath("viceroy") because PATH is unreliable across OS platforms, + // but also we actually install Viceroy in the same location as the + // application configuration, which means it wouldn't be found looking up by + // the PATH env var. We could pass the path for the application configuration + // into exec.LookPath() but it's simpler to just execute the binary. + // + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with variable + // Disabling as the variables come from trusted sources. + /* #nosec */ + // nosemgrep + command := exec.Command(bin, "--version") + + var installedVersion string + + stdoutStderr, err := command.CombinedOutput() + if err != nil { + vi.Globals.ErrLog.Add(err) + } else { + // Check the version output has the expected format: `viceroy 0.1.0` + installedVersion = strings.TrimSpace(string(stdoutStderr)) + segs := strings.Split(installedVersion, " ") + if len(segs) < 2 { + return bin, viceroyError + } + installedVersion = segs[1] + } + + // If the user hasn't explicitly set a Viceroy version, then we'll use + // whatever the latest version is. + versionToInstall := "latest" + if v := vi.Versioner.RequestedVersion(); v != "" { + versionToInstall = v + + if _, err := semver.Parse(versionToInstall); err != nil { + return bin, fsterr.RemediationError{ + Inner: fmt.Errorf("failed to parse configured version as a semver: %w", err), + Remediation: fmt.Sprintf("Ensure the %s `viceroy_version` value '%s' (under the [local_server] section) is a valid semver (https://semver.org/), e.g. `0.1.0`)", manifestPath, versionToInstall), + } + } + } + + err = vi.install(installedVersion, versionToInstall, manifestPath, bin, spinner) + if err != nil { + vi.Globals.ErrLog.Add(err) + return bin, err + } + + err = github.SetBinPerms(bin) + if err != nil { + vi.Globals.ErrLog.Add(err) + return bin, err + } + return bin, nil +} + +// checkViceroyEnvVar indicates if the CLI should use a Viceroy binary exposed +// on the user's $PATH. +func checkViceroyEnvVar(value string) bool { + switch strings.ToUpper(value) { + case "1", "TRUE": + return true + } + return false +} + +// install downloads the binary from GitHub. +// +// The logic flow is as follows: +// +// 1. Check if version to install is "latest" +// 2. If so, check the latest release matches the installed version. +// 3. If not latest, check the installed version matches the expected version. +func (vi viceroyInstaller) install( + installedVersion, versionToInstall, manifestPath, bin string, + spinner text.Spinner, +) error { + var ( + err error + msg, tmpBin string + ) + + switch { + case installedVersion == "": // Viceroy not installed + if vi.Globals.Verbose() { + text.Info(vi.Globals.Output, "Viceroy is not already installed, so we will install the %s version.\n\n", versionToInstall) + } + err = spinner.Start() + if err != nil { + return err + } + msg = fmt.Sprintf("Fetching Viceroy release: %s", versionToInstall) + spinner.Message(msg + "...") + + if versionToInstall == "latest" { + tmpBin, err = vi.Versioner.DownloadLatest() + } else { + tmpBin, err = vi.Versioner.DownloadVersion(versionToInstall) + } + case versionToInstall != "latest": + if installedVersion == versionToInstall { + if vi.Globals.Verbose() { + text.Info(vi.Globals.Output, "Viceroy is already installed, and the installed version matches the required version (%s) in the %s file.\n\n", versionToInstall, manifestPath) + } + return nil + } + if vi.Globals.Verbose() { + text.Info(vi.Globals.Output, "Viceroy is already installed, but the installed version (%s) doesn't match the required version (%s) specified in the %s file.\n\n", installedVersion, versionToInstall, manifestPath) + } + + err = spinner.Start() + if err != nil { + return err + } + msg = fmt.Sprintf("Fetching Viceroy release: %s", versionToInstall) + spinner.Message(msg + "...") + + tmpBin, err = vi.Versioner.DownloadVersion(versionToInstall) + case versionToInstall == "latest": + // Viceroy is already installed, so we check if the installed version matches the latest. + // But we'll skip that check if the TTL for the Viceroy LastChecked hasn't expired. + + stale := check.Stale(vi.Globals.Config.Viceroy.LastChecked, vi.Globals.Config.Viceroy.TTL) + if !stale && !vi.ForceCheckLatest { + if vi.Globals.Verbose() { + text.Info(vi.Globals.Output, "Viceroy is installed but the CLI config (`fastly config`) shows the TTL, checking for a newer version, hasn't expired. To force a refresh, re-run the command with the `--viceroy-check` flag.\n\n") + } + return nil + } + + // IMPORTANT: We declare separately so to shadow `err` from parent scope. + var latestVersion string + + // NOTE: We won't stop the user because although we can't request the latest + // version of the tool, the user may have a local version already installed. + err = spinner.Process("Checking latest Viceroy release", func(_ *text.SpinnerWrapper) error { + latestVersion, err = vi.Versioner.LatestVersion() + if err != nil { + return fsterr.RemediationError{ + Inner: fmt.Errorf("error fetching latest version: %w", err), + Remediation: fsterr.NetworkRemediation, + } + } + return nil + }) + if err != nil { + return nil // short-circuit the rest of this function + } + + viceroyConfig := vi.Globals.Config.Viceroy + viceroyConfig.LatestVersion = latestVersion + viceroyConfig.LastChecked = time.Now().Format(time.RFC3339) + + // Before attempting to write the config data back to disk we need to + // ensure we reassign the modified struct which is a copy (not reference). + vi.Globals.Config.Viceroy = viceroyConfig + + err = vi.Globals.Config.Write(vi.Globals.ConfigPath) + if err != nil { + return err + } + + if vi.Globals.Verbose() { + text.Info(vi.Globals.Output, "\nThe CLI config (`fastly config`) has been updated with the latest Viceroy version: %s\n\n", latestVersion) + } + + if installedVersion != "" && installedVersion == latestVersion { + return nil + } + + err = spinner.Start() + if err != nil { + return err + } + msg = fmt.Sprintf("Fetching Viceroy release: %s", versionToInstall) + spinner.Message(msg + "...") + + tmpBin, err = vi.Versioner.DownloadLatest() + } + + // NOTE: The above `switch` needs to shadow the function-level `err` variable. + if err != nil { + err = fmt.Errorf("error downloading Viceroy release: %w", err) + spinner.StopFailMessage(msg) + spinErr := spinner.StopFail() + if spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return err + } + defer os.RemoveAll(tmpBin) + + if err := os.Rename(tmpBin, bin); err != nil { + err = fmt.Errorf("failed to rename/move file: %w", err) + if copyErr := filesystem.CopyFile(tmpBin, bin); copyErr != nil { + err = fmt.Errorf("failed to copy file: %w (original error: %w)", copyErr, err) + spinner.StopFailMessage(msg) + spinErr := spinner.StopFail() + if spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return err + } + } + + spinner.StopMessage(msg) + return spinner.Stop() +}