Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
8 changes: 7 additions & 1 deletion cli/azd/pkg/ext/hooks_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"strings"

"github.com/azure/azure-dev/cli/azd/pkg/alpha"
"github.com/azure/azure-dev/cli/azd/pkg/environment"
"github.com/azure/azure-dev/cli/azd/pkg/exec"
"github.com/azure/azure-dev/cli/azd/pkg/input"
Expand Down Expand Up @@ -128,7 +129,12 @@ func (h *HooksRunner) GetScript(hookConfig *HookConfig, envVars []string) (tools
case ShellTypeBash:
return bash.NewBashScript(h.commandRunner, h.cwd, envVars), nil
case ShellTypePowershell:
return powershell.NewPowershellScript(h.commandRunner, h.cwd, envVars), nil
var alphaFeaturesManager *alpha.FeatureManager
if err := h.serviceLocator.Resolve(&alphaFeaturesManager); err != nil {
// If we can't resolve the alpha features manager, continue without it
alphaFeaturesManager = nil
}
return powershell.NewPowershellScript(h.commandRunner, h.cwd, envVars, alphaFeaturesManager), nil
default:
return nil, fmt.Errorf(
"shell type '%s' is not a valid option. Only 'sh' and 'pwsh' are supported",
Expand Down
100 changes: 78 additions & 22 deletions cli/azd/pkg/tools/powershell/powershell.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

// Package powershell provides functionality to execute PowerShell scripts.
//
// PowerShell 5 Fallback Feature:
// When the alpha feature "powershell.fallback5" is enabled, the PowerShell script runner
// will attempt to fallback to PowerShell 5 (powershell.exe) if PowerShell 7 (pwsh.exe)
// is not available.
//
// To enable this feature:
// azd config set alpha.powershell.fallback5 on
//
// To disable this feature:
// azd config set alpha.powershell.fallback5 off
//
// When enabled:
// - First tries to use PowerShell 7 (pwsh)
// - If PowerShell 7 is not available, falls back to PowerShell 5 (powershell)
// - If neither is available, returns an appropriate error message
//
// When disabled (default):
// - Only tries to use PowerShell 7 (pwsh)
// - If PowerShell 7 is not available, returns an error with instructions to install PowerShell 7

package powershell

import (
Expand All @@ -9,18 +31,20 @@
"strings"

"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
"github.com/azure/azure-dev/cli/azd/pkg/exec"
"github.com/azure/azure-dev/cli/azd/pkg/output"
"github.com/azure/azure-dev/cli/azd/pkg/tools"
)

// Creates a new PowershellScript command runner
func NewPowershellScript(commandRunner exec.CommandRunner, cwd string, envVars []string) tools.Script {
func NewPowershellScript(commandRunner exec.CommandRunner, cwd string, envVars []string, alphaFeaturesManager *alpha.FeatureManager) tools.Script {

Check failure on line 41 in cli/azd/pkg/tools/powershell/powershell.go

View workflow job for this annotation

GitHub Actions / azd-lint (ubuntu-latest)

The line is 147 characters long, which exceeds the maximum of 125 characters. (lll)
return &powershellScript{
commandRunner: commandRunner,
cwd: cwd,
envVars: envVars,
checkInstalled: checkPath,
commandRunner: commandRunner,
cwd: cwd,
envVars: envVars,
checkInstalled: checkPath,
alphaFeaturesManager: alphaFeaturesManager,
}
}

Expand All @@ -29,40 +53,72 @@
commandRunner exec.CommandRunner,
cwd string,
envVars []string,
mockCheckPath checkInstalled) tools.Script {
mockCheckPath checkInstalled,
alphaFeaturesManager *alpha.FeatureManager) tools.Script {
return &powershellScript{
commandRunner: commandRunner,
cwd: cwd,
envVars: envVars,
checkInstalled: mockCheckPath,
commandRunner: commandRunner,
cwd: cwd,
envVars: envVars,
checkInstalled: mockCheckPath,
alphaFeaturesManager: alphaFeaturesManager,
}
}

type powershellScript struct {
commandRunner exec.CommandRunner
cwd string
envVars []string
checkInstalled checkInstalled
commandRunner exec.CommandRunner
cwd string
envVars []string
checkInstalled checkInstalled
alphaFeaturesManager *alpha.FeatureManager
}

type checkInstalled func(options tools.ExecOptions) error
type checkInstalled func(options tools.ExecOptions, enableFallback bool) (command string, err error)

func checkPath(options tools.ExecOptions, enableFallback bool) (command string, err error) {
// First try pwsh (PowerShell 7)
pwshCommand := strings.Split(options.UserPwsh, " ")[0]
err = tools.ToolInPath(pwshCommand)
if err == nil {
return options.UserPwsh, nil
}

// If pwsh is not available and fallback is enabled, try powershell (PowerShell 5)
if enableFallback {
ps5Err := tools.ToolInPath("powershell")
if ps5Err == nil {
return "powershell", nil
}
// Both failed, return a descriptive error
return "", fmt.Errorf("neither PowerShell 7 (%s) nor PowerShell 5 (powershell) found in PATH", pwshCommand)
}

func checkPath(options tools.ExecOptions) (err error) {
return tools.ToolInPath(strings.Split(options.UserPwsh, " ")[0])
// Return original error if no fallback
return "", err
}

// Executes the specified powershell script
// When interactive is true will attach to stdin, stdout & stderr
func (bs *powershellScript) Execute(ctx context.Context, path string, options tools.ExecOptions) (exec.RunResult, error) {
if err := bs.checkInstalled(options); err != nil {
// Check if PowerShell 5 fallback is enabled
enableFallback := bs.alphaFeaturesManager != nil && bs.alphaFeaturesManager.IsEnabled(alpha.FeatureId("powershell.fallback5"))

Check failure on line 103 in cli/azd/pkg/tools/powershell/powershell.go

View workflow job for this annotation

GitHub Actions / azd-lint (ubuntu-latest)

The line is 130 characters long, which exceeds the maximum of 125 characters. (lll)

command, err := bs.checkInstalled(options, enableFallback)
if err != nil {
suggestion := fmt.Sprintf("PowerShell 7 is not installed or not in the path. To install PowerShell 7, visit %s",
output.WithLinkFormat("https://learn.microsoft.com/powershell/scripting/install/installing-powershell"))

if enableFallback {
suggestion = fmt.Sprintf("PowerShell is not available. Either install PowerShell 7 from %s or ensure PowerShell 5 is available",

Check failure on line 111 in cli/azd/pkg/tools/powershell/powershell.go

View workflow job for this annotation

GitHub Actions / azd-lint (ubuntu-latest)

The line is 140 characters long, which exceeds the maximum of 125 characters. (lll)
output.WithLinkFormat("https://learn.microsoft.com/powershell/scripting/install/installing-powershell"))
}

return exec.RunResult{}, &internal.ErrorWithSuggestion{
Err: err,
Suggestion: fmt.Sprintf("PowerShell 7 is not installed or not in the path. To install PowerShell 7, visit %s",
output.WithLinkFormat("https://learn.microsoft.com/powershell/scripting/install/installing-powershell")),
Err: err,
Suggestion: suggestion,
}
}

runArgs := exec.NewRunArgs(options.UserPwsh, path).
runArgs := exec.NewRunArgs(command, path).
WithCwd(bs.cwd).
WithEnv(bs.envVars).
WithShell(true)
Expand Down
193 changes: 183 additions & 10 deletions cli/azd/pkg/tools/powershell/powershell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"testing"

"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
"github.com/azure/azure-dev/cli/azd/pkg/config"
"github.com/azure/azure-dev/cli/azd/pkg/exec"
"github.com/azure/azure-dev/cli/azd/pkg/tools"
"github.com/azure/azure-dev/cli/azd/test/mocks"
Expand Down Expand Up @@ -44,9 +46,10 @@
mockContext.CommandRunner,
workingDir,
env,
func(options tools.ExecOptions) error {
return nil
})
func(options tools.ExecOptions, enableFallback bool) (string, error) {
return options.UserPwsh, nil
},
nil)
runResult, err := PowershellScript.Execute(
*mockContext.Context,
scriptPath,
Expand All @@ -70,9 +73,10 @@
mockContext.CommandRunner,
workingDir,
env,
func(options tools.ExecOptions) error {
return nil
})
func(options tools.ExecOptions, enableFallback bool) (string, error) {
return options.UserPwsh, nil
},
nil)
runResult, err := PowershellScript.Execute(
*mockContext.Context,
scriptPath,
Expand All @@ -86,7 +90,7 @@
t.Run("NoPowerShellInstalled", func(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())

PowershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env)
PowershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env, nil)
_, err := PowershellScript.Execute(
*mockContext.Context,
scriptPath,
Expand Down Expand Up @@ -119,13 +123,182 @@
mockContext.CommandRunner,
workingDir,
env,
func(options tools.ExecOptions) error {
return nil
})
func(options tools.ExecOptions, enableFallback bool) (string, error) {
return options.UserPwsh, nil
},
nil)
runResult, err := PowershellScript.Execute(*mockContext.Context, scriptPath, test.value)

require.NotNil(t, runResult)
require.NoError(t, err)
})
}
}

func Test_Alpha_Feature_Detection(t *testing.T) {
t.Run("CheckIfFeatureExistsInRealFeaturesList", func(t *testing.T) {
// Check if our feature exists in the features list
featureId, isValidFeature := alpha.IsFeatureKey("powershell.fallback5")
require.True(t, isValidFeature, "powershell.fallback5 should be a valid alpha feature")
require.Equal(t, alpha.FeatureId("powershell.fallback5"), featureId)
})

t.Run("FeatureEnabled", func(t *testing.T) {
mockConfig := config.NewConfig(nil)
err := mockConfig.Set("alpha.powershell.fallback5", "on")
require.NoError(t, err)

alphaManager := alpha.NewFeaturesManagerWithConfig(mockConfig)

enabled := alphaManager.IsEnabled(alpha.FeatureId("powershell.fallback5"))
require.True(t, enabled, "powershell.fallback5 feature should be enabled")
})

t.Run("FeatureDisabled", func(t *testing.T) {
alphaManager := alpha.NewFeaturesManagerWithConfig(config.NewConfig(nil))

enabled := alphaManager.IsEnabled(alpha.FeatureId("powershell.fallback5"))
require.False(t, enabled, "powershell.fallback5 feature should be disabled by default")
})
}

func Test_Powershell_Fallback_To_PowerShell5(t *testing.T) {
workingDir := "cwd"
scriptPath := "path/script.ps1"
env := []string{"a=apple", "b=banana"}

t.Run("FallbackEnabled_PwshNotAvailable_PowerShellAvailable", func(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())

// Create a mock config that enables the fallback feature
mockConfig := config.NewConfig(nil)
err := mockConfig.Set("alpha.powershell.fallback5", "on")
require.NoError(t, err)
alphaManager := alpha.NewFeaturesManagerWithConfig(mockConfig)

// Mock command runner to expect 'powershell' command
mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool {
return strings.Contains(args.Cmd, "powershell")
}).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) {
require.Equal(t, "powershell", args.Cmd)
require.Equal(t, workingDir, args.Cwd)
require.Equal(t, scriptPath, args.Args[0])
require.Equal(t, env, args.Env)
return exec.NewRunResult(0, "", ""), nil
})

PowershellScript := NewPowershellScriptWithMockCheckPath(
mockContext.CommandRunner,
workingDir,
env,
func(options tools.ExecOptions, enableFallback bool) (string, error) {
// Simulate pwsh not available, but powershell is available
if enableFallback {
return "powershell", nil
}
return "", errors.New("pwsh not found")
},
alphaManager)

runResult, err := PowershellScript.Execute(
*mockContext.Context,
scriptPath,
tools.ExecOptions{UserPwsh: "pwsh", Interactive: to.Ptr(true)},
)

require.NotNil(t, runResult)
require.NoError(t, err)
})

t.Run("FallbackDisabled_PwshNotAvailable_ShouldFail", func(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())

// No alpha config, so fallback is disabled
alphaManager := alpha.NewFeaturesManagerWithConfig(config.NewConfig(nil))

PowershellScript := NewPowershellScriptWithMockCheckPath(
mockContext.CommandRunner,
workingDir,
env,
func(options tools.ExecOptions, enableFallback bool) (string, error) {
// Simulate pwsh not available and fallback disabled
require.False(t, enableFallback)
return "", errors.New("pwsh not found")
},
alphaManager)

_, err := PowershellScript.Execute(
*mockContext.Context,
scriptPath,
tools.ExecOptions{UserPwsh: "pwsh", Interactive: to.Ptr(true)},
)

require.Error(t, err)
require.Contains(t, err.Error(), "pwsh not found")
})

t.Run("FallbackEnabled_BothNotAvailable_ShouldFail", func(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())

// Enable fallback feature
mockConfig := config.NewConfig(nil)
err := mockConfig.Set("alpha.powershell.fallback5", "on")
require.NoError(t, err)
alphaManager := alpha.NewFeaturesManagerWithConfig(mockConfig)

PowershellScript := NewPowershellScriptWithMockCheckPath(
mockContext.CommandRunner,
workingDir,
env,
func(options tools.ExecOptions, enableFallback bool) (string, error) {
// Simulate both pwsh and powershell not available
require.True(t, enableFallback)
return "", errors.New("neither PowerShell 7 (pwsh) nor PowerShell 5 (powershell) found in PATH")
},
alphaManager)

_, execErr := PowershellScript.Execute(
*mockContext.Context,
scriptPath,
tools.ExecOptions{UserPwsh: "pwsh", Interactive: to.Ptr(true)},
)

require.Error(t, execErr)
require.Contains(t, execErr.Error(), "neither PowerShell 7 (pwsh) nor PowerShell 5 (powershell) found in PATH")
})
}

func Test_Powershell_Integration_CheckPathFunction(t *testing.T) {
// Test the actual checkPath function behavior
t.Run("FallbackDisabled_PwshAvailable", func(t *testing.T) {
options := tools.ExecOptions{UserPwsh: "pwsh"}
// This may fail in environments where pwsh is not installed, which is expected
command, err := checkPath(options, false)
if err == nil {
require.Equal(t, "pwsh", command)
}
// If pwsh is not available, the function should return an error
})

Check failure on line 282 in cli/azd/pkg/tools/powershell/powershell_test.go

View workflow job for this annotation

GitHub Actions / azd-lint (ubuntu-latest)

File is not properly formatted (gofmt)
t.Run("FallbackEnabled_BothAvailable_ShouldPreferPwsh", func(t *testing.T) {
options := tools.ExecOptions{UserPwsh: "pwsh"}
// This may fail in environments where pwsh is not installed
command, err := checkPath(options, true)
if err == nil {
require.Equal(t, "pwsh", command)
}
// If neither is available, the function should return an error
})

t.Run("FallbackEnabled_ErrorMessage_ShouldMentionBoth", func(t *testing.T) {
options := tools.ExecOptions{UserPwsh: "nonexistent-pwsh-command"}
// Use a command that definitely doesn't exist
_, err := checkPath(options, true)
if err != nil {
// When fallback is enabled and both fail, error should mention both
require.Contains(t, err.Error(), "neither PowerShell 7")
require.Contains(t, err.Error(), "PowerShell 5")
require.Contains(t, err.Error(), "PATH")
}
})
}
Loading
Loading