Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/azd/.vscode/cspell-azd-dictionary.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
AADSTS
ABRT
ACCESSTOKEN
ALLUSERS
AZCLI
AZURECLI
AZURESUBSCRIPTION
Expand Down Expand Up @@ -176,6 +177,7 @@ ldflags
lechnerc77
libc
llms
INSTALLDIR
localtools
maml
mcptools
Expand Down
2 changes: 1 addition & 1 deletion cli/azd/cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ func (a *updateAction) Run(ctx context.Context) (*actions.ActionResult, error) {
return &actions.ActionResult{
Message: &actions.ResultMessage{
Header: fmt.Sprintf(
"Successfully updated azd to version %s. Changes take effect on next invocation.",
"Updated azd to version %s. Changes take effect on next invocation.",
versionInfo.Version,
),
},
Expand Down
1 change: 1 addition & 0 deletions cli/azd/pkg/update/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const (
CodeSignatureInvalid = "update.signatureInvalid"
CodeElevationRequired = "update.elevationRequired"
CodeUnsupportedInstallMethod = "update.unsupportedInstallMethod"
CodeNonStandardInstall = "update.nonStandardInstall"
)

func newUpdateError(code string, err error) *UpdateError {
Expand Down
112 changes: 70 additions & 42 deletions cli/azd/pkg/update/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,43 +315,81 @@ func (m *Manager) updateViaPackageManager(
}

func (m *Manager) updateViaMSI(ctx context.Context, cfg *UpdateConfig, writer io.Writer) error {
msiURL, err := m.buildMSIDownloadURL(cfg.Channel)
if err != nil {
// Verify the install is the standard per-user MSI configuration.
// install-azd.ps1 installs with ALLUSERS=2 to %LOCALAPPDATA%\Programs\Azure Dev CLI.
// If the current install is non-standard, abort and advise the user.
if err := isStandardMSIInstall(); err != nil {
return err
}
Comment thread
hemarina marked this conversation as resolved.

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

tempDir := os.TempDir()
msiPath := filepath.Join(tempDir, "azd-windows-amd64.msi")
// Track whether the install succeeded so we know whether to restore or clean up.
updateSucceeded := false
defer func() {
if updateSucceeded {
// Remove the temp backup directory. If this fails, the OS
// will clean it up eventually since it lives under %TEMP%.
_ = os.RemoveAll(filepath.Dir(backupPath))
return
}
// Update failed — restore the backup so the user has the original binary.
fmt.Fprintf(writer, "Restoring previous version...\n")
if restoreErr := restoreExeFromBackup(originalPath, backupPath); restoreErr != nil {
fmt.Fprintf(writer, "WARNING: failed to restore previous version: %v\n", restoreErr)
fmt.Fprintf(writer, "Your backup is at: %s\n", backupPath)
fmt.Fprintf(writer, "To recover manually, copy it to: %s\n", originalPath)
}
}()
Comment thread
hemarina marked this conversation as resolved.

if err := m.downloadFile(ctx, msiURL, msiPath, writer); err != nil {
return newUpdateError(CodeDownloadFailed, err)
// Run the install script synchronously. The MSI overwrites the unlocked
// safety copy at the original path with the new version.
psArgs := buildInstallScriptArgs(cfg.Channel)

// Snapshot the safety copy's mod time before the install so we can detect
// whether the MSI actually replaced the file. A plain os.Stat after install
// would always succeed because the safety copy already exists at originalPath.
preInfo, statErr := os.Stat(originalPath)
if statErr != nil {
return newUpdateError(CodeReplaceFailed,
fmt.Errorf("failed to stat safety copy before install: %w", statErr))
}
// Don't defer os.Remove — the detached msiexec process needs this file after we exit.

// Build msiexec args. Always write a verbose log so failures are diagnosable.
msiLogPath, logErr := msiLogFilePath()
args := []string{"/i", msiPath, "/qn"}
if logErr == nil {
args = append(args, "/l*v", msiLogPath)
log.Printf("MSI install log: %s", msiLogPath)
log.Printf("Running install script: powershell %s", strings.Join(psArgs, " "))
fmt.Fprintf(writer, "Installing azd %s channel...\n", cfg.Channel)

runArgs := exec.NewRunArgs("powershell", psArgs...).
WithStdOut(writer).
WithStdErr(writer)

if _, err := m.commandRunner.Run(ctx, runArgs); err != nil {
return newUpdateError(CodeReplaceFailed, fmt.Errorf("install script failed: %w", err))
}

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

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

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

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

func (m *Manager) buildMSIDownloadURL(channel Channel) (string, error) {
var folder string
switch channel {
case ChannelStable:
folder = "stable"
case ChannelDaily:
folder = "daily"
default:
return "", fmt.Errorf("unsupported channel: %s", channel)
}

return fmt.Sprintf("%s/%s/azd-windows-%s.msi", blobBaseURL, folder, runtime.GOARCH), nil
}

func archiveExtension() string {
if runtime.GOOS == "linux" {
return ".tar.gz"
Expand Down Expand Up @@ -590,13 +614,17 @@ func (m *Manager) replaceBinary(ctx context.Context, newBinaryPath, currentBinar
return fmt.Errorf("failed to replace binary: %w", err)
}

// currentExePath returns the resolved path of the currently running azd binary.
// currentExePath returns the resolved path of the currently running executable.
func currentExePath() (string, error) {
exePath, err := os.Executable()
if err != nil {
return "", err
return "", fmt.Errorf("failed to determine current executable path: %w", err)
}
resolved, err := filepath.EvalSymlinks(exePath)
if err != nil {
return "", fmt.Errorf("failed to resolve executable path: %w", err)
}
return filepath.EvalSymlinks(exePath)
return resolved, nil
}

func copyFile(src, dst string) error {
Expand Down
21 changes: 13 additions & 8 deletions cli/azd/pkg/update/msi_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@

package update

import "syscall"
// isStandardMSIInstall is a no-op on non-Windows platforms.
func isStandardMSIInstall() error {
return nil
}

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

// msiLogFilePath is a no-op on non-Windows platforms.
func msiLogFilePath() (string, error) {
return "", nil
// restoreExeFromBackup is a no-op stub on non-Windows platforms.
func restoreExeFromBackup(_, _ string) error { return nil }

// buildInstallScriptArgs is a no-op on non-Windows platforms.
func buildInstallScriptArgs(_ Channel) []string {
return nil
}
Loading
Loading