Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: install CLI version command #1104

Merged
merged 2 commits into from
Dec 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/app/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ dictionary
dictionary-entry
domain
healthcheck
install
ip-list
kv-store
kv-store-entry
Expand Down
1 change: 1 addition & 0 deletions pkg/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ func IsVerboseAndQuiet(args []string) bool {
// We hack a solution in ../app/run.go (`configureKingpin` function).
func IsGlobalFlagsOnly(args []string) bool {
// Global flags are defined in ../app/run.go
// nosemgrep: trailofbits.go.iterate-over-empty-map.iterate-over-empty-map (false positive)
globals := map[string]int{
"--accept-defaults": 0,
"-d": 0,
Expand Down
3 changes: 3 additions & 0 deletions pkg/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/fastly/cli/pkg/commands/dictionaryentry"
"github.com/fastly/cli/pkg/commands/domain"
"github.com/fastly/cli/pkg/commands/healthcheck"
"github.com/fastly/cli/pkg/commands/install"
"github.com/fastly/cli/pkg/commands/ip"
"github.com/fastly/cli/pkg/commands/kvstore"
"github.com/fastly/cli/pkg/commands/kvstoreentry"
Expand Down Expand Up @@ -169,6 +170,7 @@ func Define(
healthcheckDescribe := healthcheck.NewDescribeCommand(healthcheckCmdRoot.CmdClause, data)
healthcheckList := healthcheck.NewListCommand(healthcheckCmdRoot.CmdClause, data)
healthcheckUpdate := healthcheck.NewUpdateCommand(healthcheckCmdRoot.CmdClause, data)
installRoot := install.NewRootCommand(app, data)
ipCmdRoot := ip.NewRootCommand(app, data)
kvstoreCmdRoot := kvstore.NewRootCommand(app, data)
kvstoreCreate := kvstore.NewCreateCommand(kvstoreCmdRoot.CmdClause, data)
Expand Down Expand Up @@ -535,6 +537,7 @@ func Define(
healthcheckDescribe,
healthcheckList,
healthcheckUpdate,
installRoot,
ipCmdRoot,
kvstoreCreate,
kvstoreDelete,
Expand Down
1 change: 1 addition & 0 deletions pkg/commands/compute/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ func (c *DeployCommand) Exec(in io.Reader, out io.Writer) (err error) {
return nil // user declined service creation prompt
}
} else {
// nosemgrep: trailofbits.go.invalid-usage-of-modified-variable.invalid-usage-of-modified-variable (we only reference variable in specific error scenario)
serviceVersion, err = c.ExistingServiceVersion(serviceID, out)
if err != nil {
if errors.Is(err, ErrPackageUnchanged) {
Expand Down
1 change: 1 addition & 0 deletions pkg/commands/compute/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ type ValidateCommand struct {
//
// NOTE: This function is also called by the `deploy` command.
func validatePackageContent(pkgPath string) error {
// nosemgrep: trailofbits.go.iterate-over-empty-map.iterate-over-empty-map (false positive)
files := map[string]bool{
manifest.Filename: false,
"main.wasm": false,
Expand Down
2 changes: 2 additions & 0 deletions pkg/commands/install/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package install contains functions for installing a specific CLI version.
package install
118 changes: 118 additions & 0 deletions pkg/commands/install/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package install

import (
"fmt"
"io"
"os"
"path/filepath"

"github.com/fastly/cli/pkg/cmd"
"github.com/fastly/cli/pkg/filesystem"
"github.com/fastly/cli/pkg/global"
"github.com/fastly/cli/pkg/text"
)

// RootCommand is the parent command for all subcommands in this package.
// It should be installed under the primary root command.
type RootCommand struct {
cmd.Base

versionToInstall string
}

// NewRootCommand returns a new command registered in the parent.
func NewRootCommand(parent cmd.Registerer, g *global.Data) *RootCommand {
var c RootCommand
c.Globals = g
c.CmdClause = parent.Command("install", "Install the specified version of the CLI")
c.CmdClause.Arg("version", "CLI release version to install (e.g. 10.8.0)").Required().StringVar(&c.versionToInstall)
return &c
}

// Exec implements the command interface.
func (c *RootCommand) Exec(_ io.Reader, out io.Writer) error {
spinner, err := text.NewSpinner(out)
if err != nil {
return err
}

var downloadedBin string
err = spinner.Process(fmt.Sprintf("Fetching release %s", c.versionToInstall), func(_ *text.SpinnerWrapper) error {
downloadedBin, err = c.Globals.Versioners.CLI.DownloadVersion(c.versionToInstall)
if err != nil {
c.Globals.ErrLog.AddWithContext(err, map[string]any{
"CLI version to install": c.versionToInstall,
})
return fmt.Errorf("error downloading release version %s: %w", c.versionToInstall, err)
}
return nil
})
if err != nil {
return err
}
defer os.RemoveAll(downloadedBin)

var currentBin string
err = spinner.Process("Replacing binary", func(_ *text.SpinnerWrapper) error {
execPath, err := os.Executable()
if err != nil {
c.Globals.ErrLog.Add(err)
return fmt.Errorf("error determining executable path: %w", err)
}

currentBin, err = filepath.Abs(execPath)
if err != nil {
c.Globals.ErrLog.AddWithContext(err, map[string]any{
"Executable path": execPath,
})
return fmt.Errorf("error determining absolute target path: %w", err)
}

// Windows does not permit replacing a running executable, however it will
// permit it if you first move the original executable. So we first move the
// running executable to a new location, then we move the executable that we
// downloaded to the same location as the original.
// I've also tested this approach on nix systems and it works fine.
//
// Reference:
// https://github.com/golang/go/issues/21997#issuecomment-331744930

backup := currentBin + ".bak"
if err := os.Rename(currentBin, backup); err != nil {
c.Globals.ErrLog.AddWithContext(err, map[string]any{
"Executable (source)": downloadedBin,
"Executable (destination)": currentBin,
})
return fmt.Errorf("error moving the current executable: %w", err)
}

if err = os.Remove(backup); err != nil {
c.Globals.ErrLog.Add(err)
}

// Move the downloaded binary to the same location as the current executable.
if err := os.Rename(downloadedBin, currentBin); err != nil {
c.Globals.ErrLog.AddWithContext(err, map[string]any{
"Executable (source)": downloadedBin,
"Executable (destination)": currentBin,
})
renameErr := err

// Failing that we'll try to io.Copy downloaded binary to the current binary.
if err := filesystem.CopyFile(downloadedBin, currentBin); err != nil {
c.Globals.ErrLog.AddWithContext(err, map[string]any{
"Executable (source)": downloadedBin,
"Executable (destination)": currentBin,
})
return fmt.Errorf("error 'copying' latest binary in place: %w (following an error 'moving': %w)", err, renameErr)
}
}
return nil
})
if err != nil {
return err
}

text.Success(out, "\nInstalled version %s.", c.versionToInstall)
return nil
}