Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
261 changes: 215 additions & 46 deletions cli/azd/pkg/update/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const (
stableVersionURL = "https://aka.ms/azure-dev/versions/cli/latest"
// blobBaseURL is the base URL for Azure Blob Storage where azd binaries are hosted.
blobBaseURL = "https://azuresdkartifacts.z5.web.core.windows.net/azd/standalone/release"
// installShScriptURL is the shell install script for azd on Linux/macOS.
installShScriptURL = "https://aka.ms/install-azd.sh"
)

// VersionInfo holds the result of a version check.
Expand Down Expand Up @@ -271,12 +273,17 @@ func (m *Manager) Update(ctx context.Context, cfg *UpdateConfig, writer io.Write

switch installedBy {
case installer.InstallTypeBrew:
return m.updateViaPackageManager(ctx, "brew", []string{"upgrade", "azd"}, writer)
return m.updateViaBrew(ctx, cfg, writer)
case installer.InstallTypeWinget:
return m.updateViaPackageManager(ctx, "winget", []string{"upgrade", "Microsoft.Azd"}, writer)
case installer.InstallTypeChoco:
return m.updateViaPackageManager(ctx, "choco", []string{"upgrade", "azd"}, writer)
case installer.InstallTypePs, installer.InstallTypeSh, installer.InstallTypeDeb,
case installer.InstallTypeSh:
if runtime.GOOS == "windows" {
return m.updateViaMSI(ctx, cfg, writer)
}
return m.updateViaInstallScript(ctx, cfg, writer)
case installer.InstallTypePs, installer.InstallTypeDeb,
installer.InstallTypeRpm, installer.InstallTypeUnknown:
if runtime.GOOS == "windows" {
return m.updateViaMSI(ctx, cfg, writer)
Expand All @@ -290,6 +297,135 @@ func (m *Manager) Update(ctx context.Context, cfg *UpdateConfig, writer io.Write
}
}

func (m *Manager) updateViaBrew(ctx context.Context, cfg *UpdateConfig, writer io.Writer) error {
fmt.Fprintf(writer, "Checking Homebrew cask installation...\n")

// Determine which cask is currently installed by checking `brew list --cask`.
listArgs := exec.NewRunArgs("brew", "list", "--cask")
listResult, err := m.commandRunner.Run(ctx, listArgs)
if err != nil {
log.Printf("brew list --cask failed: %v", err)
}

caskOutput := ""
if err == nil {
caskOutput = listResult.Stdout
}

hasAzd := false
hasAzdDaily := false
for _, line := range strings.Split(caskOutput, "\n") {
name := strings.TrimSpace(line)
if name == "azd@daily" {
hasAzdDaily = true
} else if name == "azd" {
hasAzd = true
}
}

targetChannel := cfg.Channel

if !hasAzd && !hasAzdDaily {
// azd is not installed as a cask (formula install or other).
// Uninstall the non-cask version and install the correct cask.
fmt.Fprintf(writer, "azd is not installed as a Homebrew cask. Reinstalling as cask...\n")
if err := m.updateViaPackageManager(ctx, "brew", []string{"uninstall", "azd"}, writer); err != nil {
log.Printf("brew uninstall azd failed: %v", err)
}
switch targetChannel {
case ChannelStable:
return m.updateViaPackageManager(ctx, "brew", []string{"install", "--cask", "azure/azd/azd"}, writer)
case ChannelDaily:
return m.updateViaPackageManager(ctx, "brew", []string{"install", "--cask", "azure/azd/azd@daily"}, writer)
default:
return fmt.Errorf("unsupported channel: %s", targetChannel)
}
}

// Determine if the user is switching channels.
currentlyDaily := hasAzdDaily
currentlyStable := hasAzd
Comment on lines +346 to +347

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are redundant. Why not use hasAzd or hasAzdDaily directly?


if currentlyDaily && targetChannel == ChannelStable {
// Switching from daily to stable
fmt.Fprintf(writer, "Switching from daily to stable channel...\n")
if err := m.updateViaPackageManager(ctx, "brew", []string{"uninstall", "--cask", "azd@daily"}, writer); err != nil {
return err
}
return m.updateViaPackageManager(ctx, "brew", []string{"install", "--cask", "azure/azd/azd"}, writer)
}

if currentlyStable && targetChannel == ChannelDaily {
// Switching from stable to daily
fmt.Fprintf(writer, "Switching from stable to daily channel...\n")
if err := m.updateViaPackageManager(ctx, "brew", []string{"uninstall", "--cask", "azd"}, writer); err != nil {
return err
}
return m.updateViaPackageManager(ctx, "brew", []string{"install", "--cask", "azure/azd/azd@daily"}, writer)
}

// Same channel — update in place.
switch targetChannel {
case ChannelStable:
fmt.Fprintf(writer, "Updating azd (stable channel)...\n")
return m.updateViaPackageManager(ctx, "brew", []string{"upgrade", "--cask", "azure/azd/azd"}, writer)
case ChannelDaily:
fmt.Fprintf(writer, "Updating azd (daily channel)...\n")
return m.updateViaPackageManager(ctx, "brew", []string{"upgrade", "--cask", "azure/azd/azd@daily"}, writer)
default:
return fmt.Errorf("unsupported channel: %s", targetChannel)
}
}

func (m *Manager) updateViaInstallScript(ctx context.Context, cfg *UpdateConfig, writer io.Writer) error {
fmt.Fprintf(writer, "Updating azd via install script...\n")

currentPath, err := currentExePath()
if err != nil {
return fmt.Errorf("failed to determine current path: %w", err)
Comment thread
hemarina marked this conversation as resolved.
Outdated
}
installFolder := filepath.Dir(currentPath)

// Download install-azd.sh to a temp file.
tempDir := os.TempDir()
scriptPath := filepath.Join(tempDir, "install-azd.sh")
Comment thread
hemarina marked this conversation as resolved.
Outdated

if err := m.downloadFile(ctx, installShScriptURL, scriptPath, writer); err != nil {
return newUpdateError(CodeDownloadFailed, fmt.Errorf("failed to download install script: %w", err))
}
defer os.Remove(scriptPath)
Comment thread
hemarina marked this conversation as resolved.
Outdated

// Make the script executable.
if err := os.Chmod(scriptPath, 0o555); err != nil {
return newUpdateError(CodeReplaceFailed, fmt.Errorf("failed to set script permissions: %w", err))
}

versionArg := string(cfg.Channel)
runArgs := exec.NewRunArgs("bash", scriptPath,
"--version", versionArg,
"--install-folder", installFolder,
"--symlink-folder", "",
)
runArgs = runArgs.WithStdOut(writer).WithStdErr(writer).WithInteractive(true)

log.Printf("Running install script: bash %s --version %s --install-folder %s --symlink-folder \"\"",
scriptPath, versionArg, installFolder)
fmt.Fprintf(writer, "Installing azd %s channel to %s...\n", cfg.Channel, installFolder)

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

if result.ExitCode != 0 {
return newUpdateErrorf(CodeReplaceFailed,
"install script failed with exit code %d", result.ExitCode)
}

log.Printf("Install script completed successfully")
return nil
}

func (m *Manager) updateViaPackageManager(
ctx context.Context,
command string,
Expand All @@ -315,43 +451,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
}

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)
}
}()

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 +604,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 @@ -574,7 +734,12 @@ func (m *Manager) replaceBinary(ctx context.Context, newBinaryPath, currentBinar
// On unix, try with sudo if permission denied
if runtime.GOOS != "windows" {
log.Printf("direct replacement failed (%v), trying with sudo", err)
runArgs := exec.NewRunArgs("sudo", "cp", newBinaryPath, currentBinaryPath)
// Use "rm -f && cp" instead of plain "cp" to avoid "Text file busy" (ETXTBSY) errors.
// On Linux, writing to a running binary's inode fails because the kernel holds a
// write lock on executing files. Removing first unlinks the filename (the running
// process keeps the old inode alive via its fd), then cp creates a new inode.
shellCmd := fmt.Sprintf("rm -f %q && cp %q %q", currentBinaryPath, newBinaryPath, currentBinaryPath)
runArgs := exec.NewRunArgs("sudo", "sh", "-c", shellCmd)
runArgs = runArgs.WithInteractive(true)
Comment thread
hemarina marked this conversation as resolved.
Outdated
result, sudoErr := m.commandRunner.Run(ctx, runArgs)
if sudoErr != nil {
Expand All @@ -590,13 +755,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 Expand Up @@ -738,7 +907,7 @@ func extractFromZip(archivePath, binaryName, destPath string) error {
// IsPackageManagerInstall returns true if azd was installed via a package manager.
func IsPackageManagerInstall() bool {
switch installer.InstalledBy() {
case installer.InstallTypeBrew, installer.InstallTypeWinget, installer.InstallTypeChoco:
case installer.InstallTypeWinget, installer.InstallTypeChoco:
return true
default:
return false
Expand Down
Loading
Loading