diff --git a/cli/azd/pkg/update/manager.go b/cli/azd/pkg/update/manager.go index b4261a271aa..6748ac0009c 100644 --- a/cli/azd/pkg/update/manager.go +++ b/cli/azd/pkg/update/manager.go @@ -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. @@ -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", "azure/azd/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) @@ -290,6 +297,144 @@ 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.SplitSeq(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 + + 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 newUpdateError(CodeReplaceFailed, fmt.Errorf("failed to determine current path: %w", err)) + } + installFolder := filepath.Dir(currentPath) + + // Download install-azd.sh into a uniquely-created temp directory with restrictive permissions + scriptDir, err := os.MkdirTemp("", "azd-update-*") + if err != nil { + return newUpdateError(CodeDownloadFailed, fmt.Errorf("failed to create temp directory: %w", err)) + } + defer os.RemoveAll(scriptDir) + + // Restrict the temp directory so only the current user can read/write/traverse it. + if err := os.Chmod(scriptDir, 0o700); err != nil { + return newUpdateError(CodeDownloadFailed, fmt.Errorf("failed to set temp directory permissions: %w", err)) + } + + scriptPath := filepath.Join(scriptDir, "install-azd.sh") + + if err := m.downloadFile(ctx, installShScriptURL, scriptPath, writer); err != nil { + return newUpdateError(CodeDownloadFailed, fmt.Errorf("failed to download install script: %w", err)) + } + + // Make the script executable + if err := os.Chmod(scriptPath, 0o500); 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, @@ -598,15 +743,27 @@ 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) - runArgs = runArgs.WithInteractive(true) - result, sudoErr := m.commandRunner.Run(ctx, runArgs) - if sudoErr != nil { - return newUpdateError(CodeElevationFailed, sudoErr) + // Remove the destination first to avoid "Text file busy" (ETXTBSY) errors. + rmArgs := exec.NewRunArgs("sudo", "rm", "-f", currentBinaryPath) + rmArgs = rmArgs.WithInteractive(true) + rmResult, rmErr := m.commandRunner.Run(ctx, rmArgs) + if rmErr != nil { + return newUpdateError(CodeElevationFailed, fmt.Errorf("sudo rm failed: %w", rmErr)) + } + if rmResult.ExitCode != 0 { + return newUpdateErrorf(CodeElevationFailed, + "sudo rm failed with exit code %d", rmResult.ExitCode) + } + + cpArgs := exec.NewRunArgs("sudo", "cp", newBinaryPath, currentBinaryPath) + cpArgs = cpArgs.WithInteractive(true) + cpResult, cpErr := m.commandRunner.Run(ctx, cpArgs) + if cpErr != nil { + return newUpdateError(CodeElevationFailed, fmt.Errorf("sudo cp failed: %w", cpErr)) } - if result.ExitCode != 0 { + if cpResult.ExitCode != 0 { return newUpdateErrorf(CodeElevationFailed, - "sudo copy failed with exit code %d", result.ExitCode) + "sudo cp failed with exit code %d", cpResult.ExitCode) } return nil } @@ -766,7 +923,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 diff --git a/cli/azd/pkg/update/manager_test.go b/cli/azd/pkg/update/manager_test.go index 1f288d66fe6..1c5b0ae58fd 100644 --- a/cli/azd/pkg/update/manager_test.go +++ b/cli/azd/pkg/update/manager_test.go @@ -648,3 +648,374 @@ func (t *urlRewriteTransport) RoundTrip(req *http.Request) (*http.Response, erro newReq.Header = req.Header return t.base.RoundTrip(newReq) } + +func TestUpdateViaBrew(t *testing.T) { + t.Run("NotCask_Stable", func(t *testing.T) { + mockRunner := mockexec.NewMockCommandRunner() + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew list --cask" + }).Respond(exec.NewRunResult(0, "some-other-cask\n", "")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew uninstall azd" + }).Respond(exec.NewRunResult(0, "", "")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew install --cask azure/azd/azd" + }).Respond(exec.NewRunResult(0, "Installed", "")) + + m := NewManager(mockRunner, nil) + var buf bytes.Buffer + err := m.updateViaBrew(context.Background(), &UpdateConfig{Channel: ChannelStable}, &buf) + require.NoError(t, err) + require.Contains(t, buf.String(), "not installed as a Homebrew cask") + }) + + t.Run("NotCask_Daily", func(t *testing.T) { + mockRunner := mockexec.NewMockCommandRunner() + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew list --cask" + }).Respond(exec.NewRunResult(0, "some-other-cask\n", "")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew uninstall azd" + }).Respond(exec.NewRunResult(0, "", "")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew install --cask azure/azd/azd@daily" + }).Respond(exec.NewRunResult(0, "Installed", "")) + + m := NewManager(mockRunner, nil) + var buf bytes.Buffer + err := m.updateViaBrew(context.Background(), &UpdateConfig{Channel: ChannelDaily}, &buf) + require.NoError(t, err) + require.Contains(t, buf.String(), "not installed as a Homebrew cask") + }) + + t.Run("NotCask_UnsupportedChannel", func(t *testing.T) { + mockRunner := mockexec.NewMockCommandRunner() + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew list --cask" + }).Respond(exec.NewRunResult(0, "some-other-cask\n", "")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew uninstall azd" + }).Respond(exec.NewRunResult(0, "", "")) + + m := NewManager(mockRunner, nil) + var buf bytes.Buffer + err := m.updateViaBrew(context.Background(), &UpdateConfig{Channel: Channel("nightly")}, &buf) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported channel") + }) + + t.Run("SwitchDailyToStable", func(t *testing.T) { + mockRunner := mockexec.NewMockCommandRunner() + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew list --cask" + }).Respond(exec.NewRunResult(0, "azd@daily\n", "")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew uninstall --cask azd@daily" + }).Respond(exec.NewRunResult(0, "", "")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew install --cask azure/azd/azd" + }).Respond(exec.NewRunResult(0, "Installed", "")) + + m := NewManager(mockRunner, nil) + var buf bytes.Buffer + err := m.updateViaBrew(context.Background(), &UpdateConfig{Channel: ChannelStable}, &buf) + require.NoError(t, err) + require.Contains(t, buf.String(), "Switching from daily to stable") + }) + + t.Run("SwitchStableToDaily", func(t *testing.T) { + mockRunner := mockexec.NewMockCommandRunner() + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew list --cask" + }).Respond(exec.NewRunResult(0, "azd\n", "")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew uninstall --cask azd" + }).Respond(exec.NewRunResult(0, "", "")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew install --cask azure/azd/azd@daily" + }).Respond(exec.NewRunResult(0, "Installed", "")) + + m := NewManager(mockRunner, nil) + var buf bytes.Buffer + err := m.updateViaBrew(context.Background(), &UpdateConfig{Channel: ChannelDaily}, &buf) + require.NoError(t, err) + require.Contains(t, buf.String(), "Switching from stable to daily") + }) + + t.Run("SwitchDailyToStable_UninstallFails", func(t *testing.T) { + mockRunner := mockexec.NewMockCommandRunner() + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew list --cask" + }).Respond(exec.NewRunResult(0, "azd@daily\n", "")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew uninstall --cask azd@daily" + }).Respond(exec.NewRunResult(1, "", "Error: cask not installed")) + + m := NewManager(mockRunner, nil) + var buf bytes.Buffer + err := m.updateViaBrew(context.Background(), &UpdateConfig{Channel: ChannelStable}, &buf) + require.Error(t, err) + var updateErr *UpdateError + require.ErrorAs(t, err, &updateErr) + require.Equal(t, CodePackageManagerFailed, updateErr.Code) + }) + + t.Run("UpgradeStable", func(t *testing.T) { + mockRunner := mockexec.NewMockCommandRunner() + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew list --cask" + }).Respond(exec.NewRunResult(0, "azd\n", "")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew upgrade --cask azure/azd/azd" + }).Respond(exec.NewRunResult(0, "Updated", "")) + + m := NewManager(mockRunner, nil) + var buf bytes.Buffer + err := m.updateViaBrew(context.Background(), &UpdateConfig{Channel: ChannelStable}, &buf) + require.NoError(t, err) + require.Contains(t, buf.String(), "Updating azd (stable channel)") + }) + + t.Run("UpgradeDaily", func(t *testing.T) { + mockRunner := mockexec.NewMockCommandRunner() + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew list --cask" + }).Respond(exec.NewRunResult(0, "azd@daily\n", "")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew upgrade --cask azure/azd/azd@daily" + }).Respond(exec.NewRunResult(0, "Updated", "")) + + m := NewManager(mockRunner, nil) + var buf bytes.Buffer + err := m.updateViaBrew(context.Background(), &UpdateConfig{Channel: ChannelDaily}, &buf) + require.NoError(t, err) + require.Contains(t, buf.String(), "Updating azd (daily channel)") + }) + + t.Run("UpgradeStable_Fails", func(t *testing.T) { + mockRunner := mockexec.NewMockCommandRunner() + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew list --cask" + }).Respond(exec.NewRunResult(0, "azd\n", "")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew upgrade --cask azure/azd/azd" + }).Respond(exec.NewRunResult(1, "", "Error: already up-to-date")) + + m := NewManager(mockRunner, nil) + var buf bytes.Buffer + err := m.updateViaBrew(context.Background(), &UpdateConfig{Channel: ChannelStable}, &buf) + require.Error(t, err) + var updateErr *UpdateError + require.ErrorAs(t, err, &updateErr) + require.Equal(t, CodePackageManagerFailed, updateErr.Code) + }) + + t.Run("UpgradeUnsupportedChannel", func(t *testing.T) { + mockRunner := mockexec.NewMockCommandRunner() + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew list --cask" + }).Respond(exec.NewRunResult(0, "azd\n", "")) + + m := NewManager(mockRunner, nil) + var buf bytes.Buffer + err := m.updateViaBrew(context.Background(), &UpdateConfig{Channel: Channel("nightly")}, &buf) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported channel") + }) + + t.Run("ListFails_FallsBackToInstall", func(t *testing.T) { + mockRunner := mockexec.NewMockCommandRunner() + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew list --cask" + }).SetError(fmt.Errorf("brew not found")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew uninstall azd" + }).Respond(exec.NewRunResult(0, "", "")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew install --cask azure/azd/azd" + }).Respond(exec.NewRunResult(0, "Installed", "")) + + m := NewManager(mockRunner, nil) + var buf bytes.Buffer + err := m.updateViaBrew(context.Background(), &UpdateConfig{Channel: ChannelStable}, &buf) + require.NoError(t, err) + }) + + t.Run("UninstallFails_StillInstallsCask", func(t *testing.T) { + mockRunner := mockexec.NewMockCommandRunner() + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew list --cask" + }).Respond(exec.NewRunResult(0, "other-cask\n", "")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew uninstall azd" + }).Respond(exec.NewRunResult(1, "", "No such formula: azd")) + mockRunner.When(func(args exec.RunArgs, command string) bool { + return command == "brew install --cask azure/azd/azd" + }).Respond(exec.NewRunResult(0, "Installed", "")) + + m := NewManager(mockRunner, nil) + var buf bytes.Buffer + err := m.updateViaBrew(context.Background(), &UpdateConfig{Channel: ChannelStable}, &buf) + require.NoError(t, err) + }) +} + +func TestUpdateViaInstallScript(t *testing.T) { + t.Run("Success_Stable", func(t *testing.T) { + scriptContent := []byte("#!/bin/bash\necho installing") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(scriptContent))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(scriptContent) + })) + defer server.Close() + + client := &http.Client{ + Transport: &urlRewriteTransport{ + base: http.DefaultTransport, + targetURL: server.URL, + }, + } + + var capturedArgs exec.RunArgs + mockRunner := mockexec.NewMockCommandRunner() + mockRunner.When(func(args exec.RunArgs, command string) bool { + return args.Cmd == "bash" + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + capturedArgs = args + return exec.NewRunResult(0, "Installation complete", ""), nil + }) + + m := NewManager(mockRunner, client) + var buf bytes.Buffer + err := m.updateViaInstallScript(context.Background(), &UpdateConfig{Channel: ChannelStable}, &buf) + require.NoError(t, err) + require.Contains(t, buf.String(), "Updating azd via install script") + require.Contains(t, buf.String(), "Installing azd stable channel") + + // Verify bash was called with correct arguments + require.Equal(t, "bash", capturedArgs.Cmd) + require.True(t, strings.HasSuffix(capturedArgs.Args[0], "install-azd.sh")) + require.Equal(t, "--version", capturedArgs.Args[1]) + require.Equal(t, "stable", capturedArgs.Args[2]) + require.Equal(t, "--install-folder", capturedArgs.Args[3]) + require.NotEmpty(t, capturedArgs.Args[4]) // install folder path + require.Equal(t, "--symlink-folder", capturedArgs.Args[5]) + require.Equal(t, "", capturedArgs.Args[6]) + }) + + t.Run("Success_Daily", func(t *testing.T) { + scriptContent := []byte("#!/bin/bash\necho installing") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(scriptContent))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(scriptContent) + })) + defer server.Close() + + client := &http.Client{ + Transport: &urlRewriteTransport{ + base: http.DefaultTransport, + targetURL: server.URL, + }, + } + + var capturedArgs exec.RunArgs + mockRunner := mockexec.NewMockCommandRunner() + mockRunner.When(func(args exec.RunArgs, command string) bool { + return args.Cmd == "bash" + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + capturedArgs = args + return exec.NewRunResult(0, "Installation complete", ""), nil + }) + + m := NewManager(mockRunner, client) + var buf bytes.Buffer + err := m.updateViaInstallScript(context.Background(), &UpdateConfig{Channel: ChannelDaily}, &buf) + require.NoError(t, err) + require.Contains(t, buf.String(), "Installing azd daily channel") + require.Equal(t, "daily", capturedArgs.Args[2]) + }) + + t.Run("DownloadFailure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + client := &http.Client{ + Transport: &urlRewriteTransport{ + base: http.DefaultTransport, + targetURL: server.URL, + }, + } + + m := NewManager(nil, client) + var buf bytes.Buffer + err := m.updateViaInstallScript(context.Background(), &UpdateConfig{Channel: ChannelStable}, &buf) + require.Error(t, err) + var updateErr *UpdateError + require.ErrorAs(t, err, &updateErr) + require.Equal(t, CodeDownloadFailed, updateErr.Code) + }) + + t.Run("ScriptExecutionError", func(t *testing.T) { + scriptContent := []byte("#!/bin/bash\necho installing") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(scriptContent))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(scriptContent) + })) + defer server.Close() + + client := &http.Client{ + Transport: &urlRewriteTransport{ + base: http.DefaultTransport, + targetURL: server.URL, + }, + } + + mockRunner := mockexec.NewMockCommandRunner() + mockRunner.When(func(args exec.RunArgs, command string) bool { + return args.Cmd == "bash" + }).SetError(fmt.Errorf("bash: command not found")) + + m := NewManager(mockRunner, client) + var buf bytes.Buffer + err := m.updateViaInstallScript(context.Background(), &UpdateConfig{Channel: ChannelStable}, &buf) + require.Error(t, err) + var updateErr *UpdateError + require.ErrorAs(t, err, &updateErr) + require.Equal(t, CodeReplaceFailed, updateErr.Code) + }) + + t.Run("ScriptNonZeroExit", func(t *testing.T) { + scriptContent := []byte("#!/bin/bash\necho installing") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(scriptContent))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(scriptContent) + })) + defer server.Close() + + client := &http.Client{ + Transport: &urlRewriteTransport{ + base: http.DefaultTransport, + targetURL: server.URL, + }, + } + + mockRunner := mockexec.NewMockCommandRunner() + mockRunner.When(func(args exec.RunArgs, command string) bool { + return args.Cmd == "bash" + }).Respond(exec.NewRunResult(1, "", "installation failed")) + + m := NewManager(mockRunner, client) + var buf bytes.Buffer + err := m.updateViaInstallScript(context.Background(), &UpdateConfig{Channel: ChannelStable}, &buf) + require.Error(t, err) + var updateErr *UpdateError + require.ErrorAs(t, err, &updateErr) + require.Equal(t, CodeReplaceFailed, updateErr.Code) + }) +} diff --git a/cli/azd/pkg/update/msi_windows.go b/cli/azd/pkg/update/msi_windows.go index 4b3ff1425e8..95d2c3d8d9f 100644 --- a/cli/azd/pkg/update/msi_windows.go +++ b/cli/azd/pkg/update/msi_windows.go @@ -36,7 +36,7 @@ func expectedPerUserInstallDir() string { // This frees the original path AND keeps the running process alive. // 2. Copy the backup back to the original path (azd.exe). // This is an unlocked copy that acts as a safety net: if the process is -// killed at any point after this (Ctrl+C, power loss, ect), the user +// killed at any point after this (Ctrl+C, power loss, etc.), the user // still has a working azd.exe. // 3. The MSI installer later overwrites the unlocked safety copy with the new version. //