Skip to content

Commit e695a74

Browse files
hemarinajongio
authored andcommitted
Implement azd update to use installation script for Windows (Azure#7166)
* add install script installation for windows * address feedback * address feedback * address feedback, update UI, rename azd to temp and have a copy to be replaced by new azd exe * fix cspell and golang, address feedback * fix golang-run * clean up tests due to feedback
1 parent 3b1fb68 commit e695a74

7 files changed

Lines changed: 496 additions & 72 deletions

File tree

cli/azd/.vscode/cspell-azd-dictionary.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
AADSTS
22
ABRT
33
ACCESSTOKEN
4+
ALLUSERS
45
AZCLI
56
AZURECLI
67
AZURESUBSCRIPTION
@@ -176,6 +177,7 @@ ldflags
176177
lechnerc77
177178
libc
178179
llms
180+
INSTALLDIR
179181
localtools
180182
maml
181183
mcptools

cli/azd/cmd/update.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ func (a *updateAction) Run(ctx context.Context) (*actions.ActionResult, error) {
307307
return &actions.ActionResult{
308308
Message: &actions.ResultMessage{
309309
Header: fmt.Sprintf(
310-
"Successfully updated azd to version %s. Changes take effect on next invocation.",
310+
"Updated azd to version %s. Changes take effect on next invocation.",
311311
versionInfo.Version,
312312
),
313313
},

cli/azd/pkg/update/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const (
3535
CodeSignatureInvalid = "update.signatureInvalid"
3636
CodeElevationRequired = "update.elevationRequired"
3737
CodeUnsupportedInstallMethod = "update.unsupportedInstallMethod"
38+
CodeNonStandardInstall = "update.nonStandardInstall"
3839
)
3940

4041
func newUpdateError(code string, err error) *UpdateError {

cli/azd/pkg/update/manager.go

Lines changed: 70 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -315,43 +315,81 @@ func (m *Manager) updateViaPackageManager(
315315
}
316316

317317
func (m *Manager) updateViaMSI(ctx context.Context, cfg *UpdateConfig, writer io.Writer) error {
318-
msiURL, err := m.buildMSIDownloadURL(cfg.Channel)
319-
if err != nil {
318+
// Verify the install is the standard per-user MSI configuration.
319+
// install-azd.ps1 installs with ALLUSERS=2 to %LOCALAPPDATA%\Programs\Azure Dev CLI.
320+
// If the current install is non-standard, abort and advise the user.
321+
if err := isStandardMSIInstall(); err != nil {
320322
return err
321323
}
322324

323-
fmt.Fprintf(writer, "Downloading MSI from %s...\n", msiURL)
325+
// 1. Rename the running exe to temp (frees the path; process continues via the OS handle)
326+
// 2. Copy it back as an unlocked safety net (if killed at any point, azd.exe still exists)
327+
// 3. The MSI will overwrite the unlocked safety copy with the new version
328+
fmt.Fprintf(writer, "Backing up current azd executable...\n")
329+
originalPath, backupPath, err := backupCurrentExe()
330+
if err != nil {
331+
return newUpdateError(CodeReplaceFailed, fmt.Errorf("failed to backup current executable: %w", err))
332+
}
324333

325-
tempDir := os.TempDir()
326-
msiPath := filepath.Join(tempDir, "azd-windows-amd64.msi")
334+
// Track whether the install succeeded so we know whether to restore or clean up.
335+
updateSucceeded := false
336+
defer func() {
337+
if updateSucceeded {
338+
// Remove the temp backup directory. If this fails, the OS
339+
// will clean it up eventually since it lives under %TEMP%.
340+
_ = os.RemoveAll(filepath.Dir(backupPath))
341+
return
342+
}
343+
// Update failed — restore the backup so the user has the original binary.
344+
fmt.Fprintf(writer, "Restoring previous version...\n")
345+
if restoreErr := restoreExeFromBackup(originalPath, backupPath); restoreErr != nil {
346+
fmt.Fprintf(writer, "WARNING: failed to restore previous version: %v\n", restoreErr)
347+
fmt.Fprintf(writer, "Your backup is at: %s\n", backupPath)
348+
fmt.Fprintf(writer, "To recover manually, copy it to: %s\n", originalPath)
349+
}
350+
}()
327351

328-
if err := m.downloadFile(ctx, msiURL, msiPath, writer); err != nil {
329-
return newUpdateError(CodeDownloadFailed, err)
352+
// Run the install script synchronously. The MSI overwrites the unlocked
353+
// safety copy at the original path with the new version.
354+
psArgs := buildInstallScriptArgs(cfg.Channel)
355+
356+
// Snapshot the safety copy's mod time before the install so we can detect
357+
// whether the MSI actually replaced the file. A plain os.Stat after install
358+
// would always succeed because the safety copy already exists at originalPath.
359+
preInfo, statErr := os.Stat(originalPath)
360+
if statErr != nil {
361+
return newUpdateError(CodeReplaceFailed,
362+
fmt.Errorf("failed to stat safety copy before install: %w", statErr))
330363
}
331-
// Don't defer os.Remove — the detached msiexec process needs this file after we exit.
332364

333-
// Build msiexec args. Always write a verbose log so failures are diagnosable.
334-
msiLogPath, logErr := msiLogFilePath()
335-
args := []string{"/i", msiPath, "/qn"}
336-
if logErr == nil {
337-
args = append(args, "/l*v", msiLogPath)
338-
log.Printf("MSI install log: %s", msiLogPath)
365+
log.Printf("Running install script: powershell %s", strings.Join(psArgs, " "))
366+
fmt.Fprintf(writer, "Installing azd %s channel...\n", cfg.Channel)
367+
368+
runArgs := exec.NewRunArgs("powershell", psArgs...).
369+
WithStdOut(writer).
370+
WithStdErr(writer)
371+
372+
if _, err := m.commandRunner.Run(ctx, runArgs); err != nil {
373+
return newUpdateError(CodeReplaceFailed, fmt.Errorf("install script failed: %w", err))
339374
}
340375

341-
log.Printf("Spawning detached msiexec: msiexec %s", strings.Join(args, " "))
342-
fmt.Fprintf(writer, "Installing update via MSI...\n")
376+
// Verify the MSI actually replaced the binary by comparing mod time and
377+
// size against the pre-install safety copy. If both are identical the MSI
378+
// did not write a new file (silent failure).
379+
postInfo, statErr := os.Stat(originalPath)
380+
if statErr != nil {
381+
return newUpdateError(CodeReplaceFailed,
382+
fmt.Errorf("install script completed but %s was not found", originalPath))
383+
}
343384

344-
// Spawn msiexec detached so it can replace the running azd binary.
345-
// msiexec cannot overwrite a locked executable; by detaching, azd can exit
346-
// and release the file lock before msiexec attempts the replacement.
347-
//nolint:gosec // args are constructed from controlled constants, not user input
348-
cmd := osexec.Command("msiexec", args...)
349-
cmd.SysProcAttr = newDetachedSysProcAttr()
350-
if err := cmd.Start(); err != nil {
351-
return newUpdateError(CodeReplaceFailed, fmt.Errorf("failed to start msiexec: %w", err))
385+
if postInfo.ModTime().Equal(preInfo.ModTime()) && postInfo.Size() == preInfo.Size() {
386+
return newUpdateError(CodeReplaceFailed,
387+
fmt.Errorf("install script completed but the binary at %s was not updated "+
388+
"(file unchanged); the MSI may have failed silently", originalPath))
352389
}
353390

354-
log.Printf("msiexec started with PID %d, azd will exit to release binary lock", cmd.Process.Pid)
391+
updateSucceeded = true
392+
log.Printf("Update completed successfully")
355393
return nil
356394
}
357395

@@ -430,20 +468,6 @@ func (m *Manager) buildDownloadURL(channel Channel) (string, error) {
430468
return fmt.Sprintf("%s/%s/azd-%s-%s%s", blobBaseURL, folder, platform, arch, ext), nil
431469
}
432470

433-
func (m *Manager) buildMSIDownloadURL(channel Channel) (string, error) {
434-
var folder string
435-
switch channel {
436-
case ChannelStable:
437-
folder = "stable"
438-
case ChannelDaily:
439-
folder = "daily"
440-
default:
441-
return "", fmt.Errorf("unsupported channel: %s", channel)
442-
}
443-
444-
return fmt.Sprintf("%s/%s/azd-windows-%s.msi", blobBaseURL, folder, runtime.GOARCH), nil
445-
}
446-
447471
func archiveExtension() string {
448472
if runtime.GOOS == "linux" {
449473
return ".tar.gz"
@@ -590,13 +614,17 @@ func (m *Manager) replaceBinary(ctx context.Context, newBinaryPath, currentBinar
590614
return fmt.Errorf("failed to replace binary: %w", err)
591615
}
592616

593-
// currentExePath returns the resolved path of the currently running azd binary.
617+
// currentExePath returns the resolved path of the currently running executable.
594618
func currentExePath() (string, error) {
595619
exePath, err := os.Executable()
596620
if err != nil {
597-
return "", err
621+
return "", fmt.Errorf("failed to determine current executable path: %w", err)
622+
}
623+
resolved, err := filepath.EvalSymlinks(exePath)
624+
if err != nil {
625+
return "", fmt.Errorf("failed to resolve executable path: %w", err)
598626
}
599-
return filepath.EvalSymlinks(exePath)
627+
return resolved, nil
600628
}
601629

602630
func copyFile(src, dst string) error {

cli/azd/pkg/update/msi_unix.go

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,20 @@
55

66
package update
77

8-
import "syscall"
8+
// isStandardMSIInstall is a no-op on non-Windows platforms.
9+
func isStandardMSIInstall() error {
10+
return nil
11+
}
912

10-
// newDetachedSysProcAttr is a no-op on non-Windows platforms.
11-
// updateViaMSI is only called on Windows (guarded by runtime.GOOS check in Update).
12-
func newDetachedSysProcAttr() *syscall.SysProcAttr {
13-
return &syscall.SysProcAttr{}
13+
// backupCurrentExe is a no-op stub on non-Windows platforms.
14+
func backupCurrentExe() (string, string, error) {
15+
return "", "", nil
1416
}
1517

16-
// msiLogFilePath is a no-op on non-Windows platforms.
17-
func msiLogFilePath() (string, error) {
18-
return "", nil
18+
// restoreExeFromBackup is a no-op stub on non-Windows platforms.
19+
func restoreExeFromBackup(_, _ string) error { return nil }
20+
21+
// buildInstallScriptArgs is a no-op on non-Windows platforms.
22+
func buildInstallScriptArgs(_ Channel) []string {
23+
return nil
1924
}

0 commit comments

Comments
 (0)