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 }