Skip to content
Merged
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
2 changes: 1 addition & 1 deletion cli/azd/cmd/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ func (hra *hooksRunAction) execHook(
hookName: {hook},
}

hooksManager := ext.NewHooksManager(cwd)
hooksManager := ext.NewHooksManager(cwd, hra.commandRunner)
hooksRunner := ext.NewHooksRunner(
hooksManager, hra.commandRunner, hra.envManager, hra.console, cwd, hooksMap, hra.env, hra.serviceLocator)

Expand Down
55 changes: 53 additions & 2 deletions cli/azd/cmd/middleware/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/input"
"github.com/azure/azure-dev/cli/azd/pkg/ioc"
"github.com/azure/azure-dev/cli/azd/pkg/lazy"
"github.com/azure/azure-dev/cli/azd/pkg/output/ux"
"github.com/azure/azure-dev/cli/azd/pkg/project"
)

Expand Down Expand Up @@ -66,6 +67,13 @@ func (m *HooksMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action
return next(ctx)
}

// Validate hooks and display any warnings
if !m.options.IsChildAction(ctx) {
Comment thread
wbreza marked this conversation as resolved.
if err := m.validateHooks(ctx, projectConfig); err != nil {
return nil, fmt.Errorf("failed validating hooks, %w", err)
}
}

if err := m.registerServiceHooks(ctx, env, projectConfig); err != nil {
return nil, fmt.Errorf("failed registering service hooks, %w", err)
}
Expand Down Expand Up @@ -93,7 +101,7 @@ func (m *HooksMiddleware) registerCommandHooks(
return nil, fmt.Errorf("failed getting environment manager, %w", err)
}

hooksManager := ext.NewHooksManager(projectConfig.Path)
hooksManager := ext.NewHooksManager(projectConfig.Path, m.commandRunner)
hooksRunner := ext.NewHooksRunner(
hooksManager,
m.commandRunner,
Expand Down Expand Up @@ -152,7 +160,7 @@ func (m *HooksMiddleware) registerServiceHooks(
continue
}

serviceHooksManager := ext.NewHooksManager(service.Path())
serviceHooksManager := ext.NewHooksManager(service.Path(), m.commandRunner)
serviceHooksRunner := ext.NewHooksRunner(
serviceHooksManager,
m.commandRunner,
Expand Down Expand Up @@ -198,3 +206,46 @@ func (m *HooksMiddleware) createServiceEventHandler(
return hooksRunner.RunHooks(ctx, hookType, nil, hookName)
}
}

// validateHooks validates hook configurations and displays any warnings
func (m *HooksMiddleware) validateHooks(ctx context.Context, projectConfig *project.ProjectConfig) error {
// Get service hooks for validation
var serviceHooks []map[string][]*ext.HookConfig
stableServices, err := m.importManager.ServiceStable(ctx, projectConfig)
if err != nil {
return fmt.Errorf("failed getting services for hook validation: %w", err)
}

for _, service := range stableServices {
serviceHooks = append(serviceHooks, service.Hooks)
}

// Combine project and service hooks into a single map
allHooks := make(map[string][]*ext.HookConfig)

// Add project hooks
for hookName, hookConfigs := range projectConfig.Hooks {
allHooks[hookName] = append(allHooks[hookName], hookConfigs...)
}

// Add service hooks
for _, serviceHookMap := range serviceHooks {
for hookName, hookConfigs := range serviceHookMap {
allHooks[hookName] = append(allHooks[hookName], hookConfigs...)
}
}

// Create hooks manager and validate
hooksManager := ext.NewHooksManager(projectConfig.Path, m.commandRunner)
validationResult := hooksManager.ValidateHooks(ctx, allHooks)

// Display any warnings
for _, warning := range validationResult.Warnings {
m.console.MessageUxItem(ctx, &ux.WarningMessage{
Description: warning.Message,
})
m.console.Message(ctx, "")
}

return nil
}
234 changes: 234 additions & 0 deletions cli/azd/cmd/middleware/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package middleware
import (
"context"
"errors"
osexec "os/exec"
"strings"
"testing"

Expand Down Expand Up @@ -411,3 +412,236 @@ func ensureAzdProject(ctx context.Context, azdContext *azdcontext.AzdContext, pr

return nil
}

func Test_PowerShellWarning_WithPowerShellHooks(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
azdContext := createAzdContext(t)

envName := "test"
runOptions := Options{CommandPath: "command"}

projectConfig := project.ProjectConfig{
Name: envName,
Hooks: map[string][]*ext.HookConfig{
"preprovision": {
{
Run: "Write-Host 'hello'",
Shell: ext.ShellTypePowershell,
},
},
},
}

err := ensureAzdValid(mockContext, azdContext, envName, &projectConfig)
require.NoError(t, err)

nextFn, actionRan := createNextFn()
setupHookMock(mockContext, 0)

// Mock toolInPath to simulate pwsh not being available but powershell available
mockContext.CommandRunner.MockToolInPath("pwsh", osexec.ErrNotFound)
mockContext.CommandRunner.MockToolInPath("powershell", nil) // powershell is available

result, err := runMiddleware(mockContext, envName, &projectConfig, &runOptions, nextFn)

require.NoError(t, err)
require.NotNil(t, result)
require.True(t, *actionRan)

// Check that PowerShell warning was displayed (specifically for PowerShell 5.1)
consoleOutput := mockContext.Console.Output()
t.Logf("Console output: %v", consoleOutput)
foundWarning := false
for _, message := range consoleOutput {
if strings.Contains(message, "Your computer only has PowerShell 5.1 (`powershell`) installed") {
foundWarning = true
break
}
}
require.True(t, foundWarning, "Expected PowerShell 5.1 warning to be displayed")
}

func Test_PowerShellWarning_WithPs1FileHook(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
azdContext := createAzdContext(t)

envName := "test"
runOptions := Options{CommandPath: "command"}

projectConfig := project.ProjectConfig{
Name: envName,
Hooks: map[string][]*ext.HookConfig{
"preprovision": {
{
Run: "script.ps1", // PowerShell file extension
Shell: ext.ShellTypePowershell, // Explicitly specify shell to avoid detection issues
},
},
},
}

err := ensureAzdValid(mockContext, azdContext, envName, &projectConfig)
require.NoError(t, err)

nextFn, actionRan := createNextFn()
setupHookMock(mockContext, 0)

// Mock toolInPath to simulate pwsh not being available
mockContext.CommandRunner.MockToolInPath("pwsh", osexec.ErrNotFound)

result, err := runMiddleware(mockContext, envName, &projectConfig, &runOptions, nextFn)

require.NoError(t, err)
require.NotNil(t, result)
require.True(t, *actionRan)

// Check that PowerShell warning was displayed
consoleOutput := mockContext.Console.Output()
foundWarning := false
for _, message := range consoleOutput {
if strings.Contains(message, "PowerShell 7 (`pwsh`) commands found in project") {
foundWarning = true
break
}
}
require.True(t, foundWarning, "Expected PowerShell warning to be displayed for .ps1 file")
}

func Test_PowerShellWarning_WithoutPowerShellHooks(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
azdContext := createAzdContext(t)

envName := "test"
runOptions := Options{CommandPath: "command"}

projectConfig := project.ProjectConfig{
Name: envName,
Hooks: map[string][]*ext.HookConfig{
"precommand": {
{
Run: "echo 'hello'",
Shell: ext.ShellTypeBash,
},
},
},
}

err := ensureAzdValid(mockContext, azdContext, envName, &projectConfig)
require.NoError(t, err)

nextFn, actionRan := createNextFn()
setupHookMock(mockContext, 0)

// Mock toolInPath to simulate pwsh not being available

result, err := runMiddleware(mockContext, envName, &projectConfig, &runOptions, nextFn)

require.NoError(t, err)
require.NotNil(t, result)
require.True(t, *actionRan)

// Check that no PowerShell warning was displayed
consoleOutput := mockContext.Console.Output()
foundWarning := false
for _, message := range consoleOutput {
if strings.Contains(message, "PowerShell 7 (`pwsh`) commands found in project") {
foundWarning = true
break
}
}
require.False(t, foundWarning, "Expected no PowerShell warning for bash hooks")
}

func Test_PowerShellWarning_WithPwshAvailable(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
azdContext := createAzdContext(t)

envName := "test"
runOptions := Options{CommandPath: "command"}

projectConfig := project.ProjectConfig{
Name: envName,
Hooks: map[string][]*ext.HookConfig{
"precommand": {
{
Run: "Write-Host 'hello'",
Shell: ext.ShellTypePowershell,
},
},
},
}

err := ensureAzdValid(mockContext, azdContext, envName, &projectConfig)
require.NoError(t, err)

nextFn, actionRan := createNextFn()
setupHookMock(mockContext, 0)

// Mock toolInPath to simulate pwsh being available
mockContext.CommandRunner.MockToolInPath("pwsh", nil)

result, err := runMiddleware(mockContext, envName, &projectConfig, &runOptions, nextFn)

require.NoError(t, err)
require.NotNil(t, result)
require.True(t, *actionRan)

// Check that no PowerShell warning was displayed
consoleOutput := mockContext.Console.Output()
foundWarning := false
for _, message := range consoleOutput {
if strings.Contains(message, "PowerShell 7 (`pwsh`) commands found in project") {
foundWarning = true
break
}
}
require.False(t, foundWarning, "Expected no PowerShell warning when pwsh is available")
}

func Test_PowerShellWarning_WithNoPowerShellInstalled(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
azdContext := createAzdContext(t)

envName := "test"
runOptions := Options{CommandPath: "command"}

projectConfig := project.ProjectConfig{
Name: envName,
Hooks: map[string][]*ext.HookConfig{
"preprovision": {
{
Run: "Write-Host 'hello'",
Shell: ext.ShellTypePowershell,
},
},
},
}

err := ensureAzdValid(mockContext, azdContext, envName, &projectConfig)
require.NoError(t, err)

nextFn, actionRan := createNextFn()
setupHookMock(mockContext, 0)

// Mock toolInPath to simulate neither pwsh nor powershell being available
mockContext.CommandRunner.MockToolInPath("pwsh", osexec.ErrNotFound)
mockContext.CommandRunner.MockToolInPath("powershell", osexec.ErrNotFound)

result, err := runMiddleware(mockContext, envName, &projectConfig, &runOptions, nextFn)

require.NoError(t, err)
require.NotNil(t, result)
require.True(t, *actionRan)

// Check that the correct PowerShell warning was displayed (no PowerShell installation detected)
consoleOutput := mockContext.Console.Output()
t.Logf("Console output: %v", consoleOutput)
foundWarning := false
for _, message := range consoleOutput {
if strings.Contains(message, "No PowerShell installation detected") {
foundWarning = true
break
}
}
require.True(t, foundWarning, "Expected 'No PowerShell installation detected' warning to be displayed")
}
17 changes: 17 additions & 0 deletions cli/azd/pkg/exec/command_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type CmdTreeOptions struct {
type CommandRunner interface {
Run(ctx context.Context, args RunArgs) (RunResult, error)
RunList(ctx context.Context, commands []string, args RunArgs) (RunResult, error)
ToolInPath(name string) error
}

type RunnerOptions struct {
Expand Down Expand Up @@ -253,6 +254,22 @@ func (r *commandRunner) RunList(ctx context.Context, commands []string, args Run
return result, err
}

// ToolInPath checks to see if a program can be found on the PATH, as exec.LookPath
// does, returns exec.ErrNotFound in the case where os.LookPath would return
// exec.ErrNotFound and other errors.
func (r *commandRunner) ToolInPath(name string) error {
_, err := exec.LookPath(name)

switch {
case err == nil:
return nil
case errors.Is(err, exec.ErrNotFound):
return exec.ErrNotFound
default:
return fmt.Errorf("failed searching for `%s` on PATH: %w", name, err)
}
}

func appendEnv(env []string) []string {
if len(env) > 0 {
return append(os.Environ(), env...)
Expand Down
Loading
Loading