Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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/pkg/update/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const (
CodeSignatureInvalid = "update.signatureInvalid"
CodeElevationRequired = "update.elevationRequired"
CodeUnsupportedInstallMethod = "update.unsupportedInstallMethod"
CodeOtherProcessesRunning = "update.otherProcessesRunning"
CodeNonStandardInstall = "update.nonStandardInstall"
)

func newUpdateError(code string, err error) *UpdateError {
Expand Down
101 changes: 52 additions & 49 deletions cli/azd/pkg/update/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,43 +315,69 @@ 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 {
// Step 1: Check for other running azd.exe processes (same exe path, different PID).
// The MSI installer cannot replace a locked binary, so all other instances must be closed.
fmt.Fprintf(writer, "Checking for other running azd instances...\n")
if err := checkOtherAzdProcesses(ctx, m.commandRunner); err != nil {
Comment thread
hemarina marked this conversation as resolved.
Outdated
return err
}

fmt.Fprintf(writer, "Downloading MSI from %s...\n", msiURL)

tempDir := os.TempDir()
msiPath := filepath.Join(tempDir, "azd-windows-amd64.msi")

if err := m.downloadFile(ctx, msiURL, msiPath, writer); err != nil {
return newUpdateError(CodeDownloadFailed, err)
// Step 2: 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.
// 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)
// Step 3: Backup the current exe by renaming it.
// Windows allows renaming a running executable — the OS handle follows the file.
// This frees the original path so the MSI installer can write the new binary.
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))
}

log.Printf("Spawning detached msiexec: msiexec %s", strings.Join(args, " "))
fmt.Fprintf(writer, "Installing update via MSI...\n")
// Ensure the backup is always handled: restored on failure, cleaned up on success.
// Using defer guarantees this runs even if an unexpected error or panic occurs.
updateSucceeded := false
defer func() {
if updateSucceeded {
cleanupOldBackups(originalPath)
return
}
// Update failed — restore the backup so the user still has a working 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, rename it to: %s\n", originalPath)
}
}()
Comment thread
hemarina marked this conversation as resolved.

// Step 4: Run the install script synchronously and wait for it to complete.
psArgs := buildInstallScriptArgs(cfg.Channel)

log.Printf("Running install script: powershell %s", strings.Join(psArgs, " "))
fmt.Fprintf(writer, "Installing azd %s channel...\n", cfg.Channel)

// 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))
cmd := osexec.Command("powershell", psArgs...)
cmd.Stdout = writer
cmd.Stderr = writer

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

Comment thread
hemarina marked this conversation as resolved.
Outdated
log.Printf("msiexec started with PID %d, azd will exit to release binary lock", cmd.Process.Pid)
// Step 5: Verify the installer actually produced a new binary at the expected path.
if _, err := os.Stat(originalPath); err != nil {
return newUpdateError(CodeReplaceFailed,
fmt.Errorf("install script completed but %s was not found", originalPath))
}
Comment thread
hemarina marked this conversation as resolved.
Outdated

updateSucceeded = true
log.Printf("Update completed successfully")
return nil
}

Expand Down Expand Up @@ -430,20 +456,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,15 +602,6 @@ 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.
func currentExePath() (string, error) {
exePath, err := os.Executable()
if err != nil {
return "", err
}
return filepath.EvalSymlinks(exePath)
}

func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
Expand Down
50 changes: 43 additions & 7 deletions cli/azd/pkg/update/msi_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,51 @@

package update

import "syscall"
import (
"context"

// 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{}
"github.com/azure/azure-dev/cli/azd/pkg/exec"
)

// checkOtherAzdProcesses is a no-op on non-Windows platforms.
func checkOtherAzdProcesses(_ context.Context, _ exec.CommandRunner) error {
return nil
}

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

// msiLogFilePath is a no-op on non-Windows platforms.
func msiLogFilePath() (string, error) {
// currentExePath is a no-op stub on non-Windows platforms.
func currentExePath() (string, error) {
return "", nil
}
Comment thread
hemarina marked this conversation as resolved.
Outdated

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

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

// cleanupOldBackups is a no-op stub on non-Windows platforms.
func cleanupOldBackups(_ string) {}

// versionFlag returns the install script parameter value for the given channel.
func versionFlag(channel Channel) string {

Check failure on line 41 in cli/azd/pkg/update/msi_unix.go

View workflow job for this annotation

GitHub Actions / azd-lint / golangci-lint (ubuntu-latest)

func versionFlag is unused (unused)
switch channel {
case ChannelDaily:
return "daily"
case ChannelStable:
return "stable"
default:
return "stable"
}
}

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