github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/internal/updater/updater.go (about)

     1  package updater
     2  
     3  import (
     4  	"bufio"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"os"
     9  	"os/exec"
    10  	"path/filepath"
    11  	"strings"
    12  
    13  	"github.com/gofrs/flock"
    14  
    15  	"github.com/ActiveState/cli/internal/analytics"
    16  	anaConst "github.com/ActiveState/cli/internal/analytics/constants"
    17  	"github.com/ActiveState/cli/internal/analytics/dimensions"
    18  	"github.com/ActiveState/cli/internal/constants"
    19  	"github.com/ActiveState/cli/internal/errs"
    20  	"github.com/ActiveState/cli/internal/fileutils"
    21  	"github.com/ActiveState/cli/internal/graph"
    22  	"github.com/ActiveState/cli/internal/installation"
    23  	"github.com/ActiveState/cli/internal/installation/storage"
    24  	"github.com/ActiveState/cli/internal/locale"
    25  	"github.com/ActiveState/cli/internal/logging"
    26  	"github.com/ActiveState/cli/internal/multilog"
    27  	"github.com/ActiveState/cli/internal/osutils"
    28  	"github.com/ActiveState/cli/internal/rtutils"
    29  	"github.com/ActiveState/cli/internal/rtutils/ptr"
    30  )
    31  
    32  const (
    33  	CfgKeyInstallVersion = "state_tool_installer_version"
    34  	InstallerName        = "state-installer" + osutils.ExeExtension
    35  )
    36  
    37  type ErrorInProgress struct{ *locale.LocalizedError }
    38  
    39  var errPrivilegeMistmatch = errs.New("Privilege mismatch")
    40  
    41  type Origin struct {
    42  	Channel string
    43  	Version string
    44  }
    45  
    46  func NewOriginDefault() *Origin {
    47  	return &Origin{
    48  		Channel: constants.ChannelName,
    49  		Version: constants.Version,
    50  	}
    51  }
    52  
    53  type AvailableUpdate struct {
    54  	Channel  string  `json:"channel"`
    55  	Version  string  `json:"version"`
    56  	Platform string  `json:"platform"`
    57  	Path     string  `json:"path"`
    58  	Sha256   string  `json:"sha256"`
    59  	Tag      *string `json:"tag,omitempty"`
    60  }
    61  
    62  func NewAvailableUpdate(channel, version, platform, path, sha256, tag string) *AvailableUpdate {
    63  	var t *string
    64  	if tag != "" {
    65  		t = &tag
    66  	}
    67  
    68  	return &AvailableUpdate{
    69  		Channel:  channel,
    70  		Version:  version,
    71  		Platform: platform,
    72  		Path:     path,
    73  		Sha256:   sha256,
    74  		Tag:      t,
    75  	}
    76  }
    77  
    78  func NewAvailableUpdateFromGraph(au *graph.AvailableUpdate) *AvailableUpdate {
    79  	if au == nil {
    80  		return &AvailableUpdate{}
    81  	}
    82  	return NewAvailableUpdate(au.Channel, au.Version, au.Platform, au.Path, au.Sha256, "")
    83  }
    84  
    85  func (u *AvailableUpdate) IsValid() bool {
    86  	return u != nil && u.Channel != "" && u.Version != "" && u.Platform != "" && u.Path != "" && u.Sha256 != ""
    87  }
    88  
    89  func (u *AvailableUpdate) Equals(origin *Origin) bool {
    90  	return u.Channel == origin.Channel && u.Version == origin.Version
    91  }
    92  
    93  type UpdateInstaller struct {
    94  	AvailableUpdate *AvailableUpdate
    95  	Origin          *Origin
    96  
    97  	url    string
    98  	tmpDir string
    99  	an     analytics.Dispatcher
   100  }
   101  
   102  // NewUpdateInstallerByOrigin returns an instance of Update. Allowing origin to
   103  // be set is useful for testing.
   104  func NewUpdateInstallerByOrigin(an analytics.Dispatcher, origin *Origin, avUpdate *AvailableUpdate) *UpdateInstaller {
   105  	apiUpdateURL := constants.APIUpdateURL
   106  	if url, ok := os.LookupEnv("_TEST_UPDATE_URL"); ok {
   107  		apiUpdateURL = url
   108  	}
   109  
   110  	return &UpdateInstaller{
   111  		AvailableUpdate: avUpdate,
   112  		Origin:          origin,
   113  		url:             apiUpdateURL + "/" + avUpdate.Path,
   114  		an:              an,
   115  	}
   116  }
   117  
   118  func NewUpdateInstaller(an analytics.Dispatcher, avUpdate *AvailableUpdate) *UpdateInstaller {
   119  	return NewUpdateInstallerByOrigin(an, NewOriginDefault(), avUpdate)
   120  }
   121  
   122  func (u *UpdateInstaller) ShouldInstall() bool {
   123  	return u.AvailableUpdate.IsValid() &&
   124  		(os.Getenv(constants.ForceUpdateEnvVarName) == "true" ||
   125  			!u.AvailableUpdate.Equals(u.Origin))
   126  }
   127  
   128  func (u *UpdateInstaller) DownloadAndUnpack() (string, error) {
   129  	if u.tmpDir != "" {
   130  		// To facilitate callers explicitly calling this method we cache the tmp dir and just return it if it's set
   131  		return u.tmpDir, nil
   132  	}
   133  
   134  	tmpDir, err := os.MkdirTemp("", "state-update")
   135  	if err != nil {
   136  		msg := anaConst.UpdateErrorTempDir
   137  		u.analyticsEvent(anaConst.ActUpdateDownload, anaConst.UpdateLabelFailed, msg)
   138  		return "", errs.Wrap(err, msg)
   139  	}
   140  
   141  	if err := NewFetcher(u.an).Fetch(u, tmpDir); err != nil {
   142  		return "", errs.Wrap(err, "Could not download and unpack update")
   143  	}
   144  
   145  	payloadDir := tmpDir
   146  	if legacyDir := filepath.Join(tmpDir, constants.LegacyToplevelInstallArchiveDir); fileutils.DirExists(legacyDir) {
   147  		payloadDir = legacyDir
   148  	}
   149  	u.tmpDir = payloadDir
   150  	return u.tmpDir, nil
   151  }
   152  
   153  func (u *UpdateInstaller) prepareInstall(installTargetPath string, args []string) (string, []string, error) {
   154  	sourcePath, err := u.DownloadAndUnpack()
   155  	if err != nil {
   156  		return "", nil, err
   157  	}
   158  	u.analyticsEvent(anaConst.ActUpdateDownload, anaConst.UpdateLabelSuccess, "")
   159  
   160  	installerPath := filepath.Join(sourcePath, InstallerName)
   161  	logging.Debug("Using installer: %s", installerPath)
   162  	if !fileutils.FileExists(installerPath) {
   163  		msg := anaConst.UpdateErrorNoInstaller
   164  		u.analyticsEvent(anaConst.ActUpdateInstall, anaConst.UpdateLabelFailed, msg)
   165  		return "", nil, errs.Wrap(err, msg)
   166  	}
   167  
   168  	if installTargetPath == "" {
   169  		installTargetPath, err = installation.InstallPathFromExecPath()
   170  		if err != nil {
   171  			msg := anaConst.UpdateErrorInstallPath
   172  			u.analyticsEvent(anaConst.ActUpdateInstall, anaConst.UpdateLabelFailed, msg)
   173  			return "", nil, errs.Wrap(err, msg)
   174  		}
   175  	}
   176  
   177  	args = append(args, "--update")
   178  	args = append([]string{installTargetPath}, args...)
   179  	return installerPath, args, nil
   180  }
   181  
   182  func (u *UpdateInstaller) InstallBlocking(installTargetPath string, args ...string) (rerr error) {
   183  	logging.Debug("InstallBlocking path: %s, args: %v", installTargetPath, args)
   184  
   185  	// Report any failure to analytics.
   186  	defer func() {
   187  		if rerr == nil {
   188  			return
   189  		}
   190  		switch {
   191  		case os.IsPermission(rerr):
   192  			u.analyticsEvent(anaConst.ActUpdateInstall, anaConst.UpdateLabelFailed, "Could not update the state tool due to insufficient permissions.")
   193  		case errs.Matches(rerr, &ErrorInProgress{}):
   194  			u.analyticsEvent(anaConst.ActUpdateInstall, anaConst.UpdateLabelFailed, anaConst.UpdateErrorInProgress)
   195  		default:
   196  			u.analyticsEvent(anaConst.ActUpdateInstall, anaConst.UpdateLabelFailed, anaConst.UpdateErrorInstallFailed)
   197  		}
   198  	}()
   199  
   200  	err := checkAdmin()
   201  	if errors.Is(err, errPrivilegeMistmatch) {
   202  		return locale.NewInputError("err_update_privilege_mismatch")
   203  	} else if err != nil {
   204  		return errs.Wrap(err, "Could not check if State Tool was installed as admin")
   205  	}
   206  
   207  	appdata, err := storage.AppDataPath()
   208  	if err != nil {
   209  		return errs.Wrap(err, "Could not detect appdata path")
   210  	}
   211  
   212  	// Protect against multiple updates happening simultaneously
   213  	lockFile := filepath.Join(appdata, "install.lock")
   214  	fileLock := flock.New(lockFile)
   215  	lockSuccess, err := fileLock.TryLock()
   216  	if err != nil {
   217  		return errs.Wrap(err, "Could not create file lock required to install update")
   218  	}
   219  	if !lockSuccess {
   220  		return &ErrorInProgress{locale.NewInputError("err_update_in_progress", "", lockFile)}
   221  	}
   222  	defer rtutils.Closer(fileLock.Unlock, &rerr)
   223  
   224  	var installerPath string
   225  	installerPath, args, err = u.prepareInstall(installTargetPath, args)
   226  	if err != nil {
   227  		return err
   228  	}
   229  
   230  	var envs []string
   231  	if u.AvailableUpdate.Tag != nil {
   232  		envs = append(envs, fmt.Sprintf("%s=%s", constants.UpdateTagEnvVarName, *u.AvailableUpdate.Tag))
   233  	}
   234  
   235  	_, _, err = osutils.ExecuteAndPipeStd(installerPath, args, envs)
   236  	if err != nil {
   237  		return errs.Wrap(err, "Could not run installer")
   238  	}
   239  
   240  	// installerPath looks like "<tempDir>/state-update\d{10}/state-install/state-installer".
   241  	updateDir := filepath.Dir(filepath.Dir(installerPath))
   242  	logging.Debug("Cleaning up temporary update directory: %s", updateDir)
   243  	if strings.HasPrefix(filepath.Base(updateDir), "state-update") {
   244  		err = os.RemoveAll(updateDir)
   245  		if err != nil {
   246  			multilog.Error("Unable to remove update directory '%s': %v", updateDir, err)
   247  		}
   248  	} else {
   249  		// Do not report to rollbar, but log the error for our integration tests to catch.
   250  		logging.Error("Did not remove temporary update directory. "+
   251  			"installerPath: %s\nupdateDir: %s\nExpected a 'state-update' prefix for the latter", installerPath, updateDir)
   252  	}
   253  
   254  	u.analyticsEvent(anaConst.ActUpdateInstall, anaConst.UpdateLabelSuccess, "")
   255  
   256  	return nil
   257  }
   258  
   259  // InstallWithProgress will fetch the update and run its installer
   260  // Leave installTargetPath empty to use the default/existing installation path
   261  func (u *UpdateInstaller) InstallWithProgress(installTargetPath string, progressCb func(string, bool)) (*os.Process, error) {
   262  	installerPath, args, err := u.prepareInstall(installTargetPath, []string{})
   263  	if err != nil {
   264  		return nil, err
   265  	}
   266  
   267  	proc, err := osutils.ExecuteAndForget(installerPath, args, func(cmd *exec.Cmd) error {
   268  		var stdout io.ReadCloser
   269  		var stderr io.ReadCloser
   270  		if stderr, err = cmd.StderrPipe(); err != nil {
   271  			return errs.Wrap(err, "Could not obtain stderr pipe")
   272  		}
   273  		if stdout, err = cmd.StdoutPipe(); err != nil {
   274  			return errs.Wrap(err, "Could not obtain stderr pipe")
   275  		}
   276  		if u.AvailableUpdate.Tag != nil {
   277  			cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%s", constants.UpdateTagEnvVarName, *u.AvailableUpdate.Tag))
   278  		}
   279  		go func() {
   280  			scanner := bufio.NewScanner(io.MultiReader(stderr, stdout))
   281  			for scanner.Scan() {
   282  				progressCb(scanner.Text(), false)
   283  			}
   284  			progressCb(scanner.Text(), true)
   285  		}()
   286  		return nil
   287  	})
   288  	if err != nil {
   289  		return nil, errs.Wrap(err, "Could not start installer")
   290  	}
   291  
   292  	if proc == nil {
   293  		return nil, errs.Wrap(err, "Could not obtain process information for installer")
   294  	}
   295  
   296  	return proc, nil
   297  }
   298  
   299  func (u *UpdateInstaller) analyticsEvent(action, label, msg string) {
   300  	dims := &dimensions.Values{}
   301  	if u.AvailableUpdate != nil {
   302  		dims.TargetVersion = ptr.To(u.AvailableUpdate.Version)
   303  	}
   304  
   305  	if msg != "" {
   306  		dims.Error = ptr.To(msg)
   307  	}
   308  
   309  	u.an.EventWithLabel(anaConst.CatUpdates, action, label, dims)
   310  }