diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 0f1f5d467a3..5b0ba86519f 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -67,14 +67,17 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/state" "github.com/azure/azure-dev/cli/azd/pkg/templates" "github.com/azure/azure-dev/cli/azd/pkg/tools/az" + "github.com/azure/azure-dev/cli/azd/pkg/tools/bash" "github.com/azure/azure-dev/cli/azd/pkg/tools/docker" "github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet" "github.com/azure/azure-dev/cli/azd/pkg/tools/git" "github.com/azure/azure-dev/cli/azd/pkg/tools/github" "github.com/azure/azure-dev/cli/azd/pkg/tools/javac" "github.com/azure/azure-dev/cli/azd/pkg/tools/kubectl" + "github.com/azure/azure-dev/cli/azd/pkg/tools/language" "github.com/azure/azure-dev/cli/azd/pkg/tools/maven" "github.com/azure/azure-dev/cli/azd/pkg/tools/node" + "github.com/azure/azure-dev/cli/azd/pkg/tools/powershell" "github.com/azure/azure-dev/cli/azd/pkg/tools/python" "github.com/azure/azure-dev/cli/azd/pkg/tools/swa" "github.com/azure/azure-dev/cli/azd/pkg/workflow" @@ -811,6 +814,18 @@ func registerCommonDependencies(container *ioc.NestedContainer) { container.MustRegisterNamedScoped(string(project.ServiceLanguageDocker), project.NewDockerProjectAsFrameworkService) + // Hook executors registered by language name (transient — fresh per hook invocation). + // The HooksRunner resolves these via serviceLocator.ResolveNamed(). + hookExecutorMap := map[language.ScriptLanguage]any{ + language.ScriptLanguageBash: bash.NewExecutor, + language.ScriptLanguagePowerShell: powershell.NewExecutor, + language.ScriptLanguagePython: language.NewPythonExecutor, + } + + for lang, constructor := range hookExecutorMap { + container.MustRegisterNamedTransient(string(lang), constructor) + } + // Pipelines container.MustRegisterScoped(pipeline.NewPipelineManager) container.MustRegisterSingleton(func(flags *pipelineConfigFlags) *pipeline.PipelineManagerArgs { diff --git a/cli/azd/cmd/hooks.go b/cli/azd/cmd/hooks.go index 6f4cce15ae4..925f9db327c 100644 --- a/cli/azd/cmd/hooks.go +++ b/cli/azd/cmd/hooks.go @@ -337,7 +337,7 @@ func (hra *hooksRunAction) execHook( hooksManager, hra.commandRunner, hra.envManager, hra.console, cwd, hooksMap, hra.env, hra.serviceLocator) // Always run in interactive mode for 'azd hooks run', to help with testing/debugging - runOptions := &tools.ExecOptions{ + runOptions := &tools.ExecutionContext{ Interactive: new(true), } diff --git a/cli/azd/cmd/hooks_test.go b/cli/azd/cmd/hooks_test.go index 5307c5cd80e..7d2ab480c67 100644 --- a/cli/azd/cmd/hooks_test.go +++ b/cli/azd/cmd/hooks_test.go @@ -16,14 +16,37 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/ext" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/azure/azure-dev/cli/azd/pkg/tools/bash" + "github.com/azure/azure-dev/cli/azd/pkg/tools/language" + "github.com/azure/azure-dev/cli/azd/pkg/tools/powershell" + "github.com/azure/azure-dev/cli/azd/pkg/tools/python" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/azure/azure-dev/cli/azd/test/mocks/mockenv" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) +// registerHookExecutors registers all hook executors as named +// transients in the mock container so that IoC resolution works +// in tests. +func registerHookExecutors(mockCtx *mocks.MockContext) { + mockCtx.Container.MustRegisterNamedTransient( + string(language.ScriptLanguageBash), bash.NewExecutor, + ) + mockCtx.Container.MustRegisterNamedTransient( + string(language.ScriptLanguagePowerShell), + powershell.NewExecutor, + ) + mockCtx.Container.MustRegisterSingleton(python.NewCli) + mockCtx.Container.MustRegisterNamedTransient( + string(language.ScriptLanguagePython), + language.NewPythonExecutor, + ) +} + func Test_HooksRunAction_RunsLayerHooks(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) env := environment.NewWithValues("test", nil) envManager := &mockenv.MockEnvManager{} envManager.On("Reload", mock.Anything, mock.Anything).Return(nil) @@ -92,6 +115,7 @@ func Test_HooksRunAction_RunsLayerHooks(t *testing.T) { func Test_HooksRunAction_FiltersLayerHooks(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) env := environment.NewWithValues("test", nil) envManager := &mockenv.MockEnvManager{} envManager.On("Reload", mock.Anything, mock.Anything).Return(nil) @@ -158,6 +182,7 @@ func Test_HooksRunAction_FiltersLayerHooks(t *testing.T) { func Test_HooksRunAction_SetsTelemetryTypeForLayer(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) env := environment.NewWithValues("test", nil) envManager := &mockenv.MockEnvManager{} envManager.On("Reload", mock.Anything, mock.Anything).Return(nil) @@ -272,5 +297,6 @@ func Test_HooksRunAction_ValidatesLayerHooksRelativeToLayerPath(t *testing.T) { err := action.validateAndWarnHooks(*mockContext.Context) require.NoError(t, err) require.False(t, layerHook.IsUsingDefaultShell()) - require.Equal(t, ext.ScriptTypeUnknown, layerHook.Shell) + // validate() infers shell type from the .sh file extension + require.Equal(t, ext.ShellTypeBash, layerHook.Shell) } diff --git a/cli/azd/cmd/middleware/hooks_test.go b/cli/azd/cmd/middleware/hooks_test.go index 524677bf5b2..15522c66ead 100644 --- a/cli/azd/cmd/middleware/hooks_test.go +++ b/cli/azd/cmd/middleware/hooks_test.go @@ -19,6 +19,10 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/ext" "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/azure/azure-dev/cli/azd/pkg/tools/bash" + "github.com/azure/azure-dev/cli/azd/pkg/tools/language" + "github.com/azure/azure-dev/cli/azd/pkg/tools/powershell" + "github.com/azure/azure-dev/cli/azd/pkg/tools/python" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/azure/azure-dev/cli/azd/test/mocks/mockenv" "github.com/azure/azure-dev/cli/azd/test/ostest" @@ -28,6 +32,7 @@ import ( func Test_CommandHooks_Middleware_WithValidProjectAndMatchingCommand(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -62,6 +67,7 @@ func Test_CommandHooks_Middleware_WithValidProjectAndMatchingCommand(t *testing. func Test_CommandHooks_Middleware_ValidProjectWithDifferentCommand(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -96,6 +102,7 @@ func Test_CommandHooks_Middleware_ValidProjectWithDifferentCommand(t *testing.T) func Test_CommandHooks_Middleware_ValidProjectWithNoHooks(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -122,6 +129,7 @@ func Test_CommandHooks_Middleware_ValidProjectWithNoHooks(t *testing.T) { func Test_CommandHooks_Middleware_PreHookWithError(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -159,6 +167,7 @@ func Test_CommandHooks_Middleware_PreHookWithError(t *testing.T) { func Test_CommandHooks_Middleware_PreHookWithErrorAndContinue(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -197,6 +206,7 @@ func Test_CommandHooks_Middleware_PreHookWithErrorAndContinue(t *testing.T) { func Test_CommandHooks_Middleware_WithCmdAlias(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -231,6 +241,7 @@ func Test_CommandHooks_Middleware_WithCmdAlias(t *testing.T) { func Test_ServiceHooks_Registered(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -293,6 +304,7 @@ func Test_ServiceHooks_Registered(t *testing.T) { func Test_ServiceHooks_ValidationUsesServicePath(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -492,6 +504,7 @@ func ensureAzdProject(ctx context.Context, azdContext *azdcontext.AzdContext, pr func Test_PowerShellWarning_WithPowerShellHooks(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -540,6 +553,7 @@ func Test_PowerShellWarning_WithPowerShellHooks(t *testing.T) { func Test_PowerShellWarning_WithPs1FileHook(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -586,6 +600,7 @@ func Test_PowerShellWarning_WithPs1FileHook(t *testing.T) { func Test_PowerShellWarning_WithoutPowerShellHooks(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -664,6 +679,7 @@ func Test_CommandHooks_ChildAction_HooksStillFire(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -706,6 +722,7 @@ func Test_CommandHooks_ChildAction_HooksStillFire(t *testing.T) { // guard in HooksMiddleware.Run() only affects validation, not hook execution itself. func Test_CommandHooks_ChildAction_SkipsValidationOnly(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -762,6 +779,7 @@ func Test_CommandHooks_ChildAction_SkipsValidationOnly(t *testing.T) { // command execution). func Test_CommandHooks_ChildAction_PreHookError_StopsAction(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -799,6 +817,7 @@ func Test_CommandHooks_ChildAction_PreHookError_StopsAction(t *testing.T) { func Test_PowerShellWarning_WithPwshAvailable(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -845,6 +864,7 @@ func Test_PowerShellWarning_WithPwshAvailable(t *testing.T) { func Test_PowerShellWarning_WithNoPowerShellInstalled(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -890,3 +910,19 @@ func Test_PowerShellWarning_WithNoPowerShellInstalled(t *testing.T) { } require.True(t, foundWarning, "Expected 'No PowerShell installation detected' warning to be displayed") } + +// registerHookExecutors registers all hook executors as named +// transients in the mock container so that IoC resolution works +// in tests. +func registerHookExecutors(mockCtx *mocks.MockContext) { + mockCtx.Container.MustRegisterNamedTransient( + string(language.ScriptLanguageBash), bash.NewExecutor, + ) + mockCtx.Container.MustRegisterNamedTransient( + string(language.ScriptLanguagePowerShell), powershell.NewExecutor, + ) + mockCtx.Container.MustRegisterSingleton(python.NewCli) + mockCtx.Container.MustRegisterNamedTransient( + string(language.ScriptLanguagePython), language.NewPythonExecutor, + ) +} diff --git a/cli/azd/cover b/cli/azd/cover new file mode 100644 index 00000000000..a61bb9d87e7 --- /dev/null +++ b/cli/azd/cover @@ -0,0 +1,32 @@ +mode: set +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:76.56,79.13 2 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:80.13,81.30 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:82.13,83.34 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:84.13,85.34 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:86.13,87.30 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:88.13,89.28 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:90.14,91.34 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:92.10,93.31 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:110.27,111.18 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:112.28,113.58 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:116.24,119.4 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:120.52,123.4 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:124.10,127.4 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:140.19,145.2 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:148.52,150.2 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:157.9,158.56 1 0 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:158.56,160.3 1 0 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:162.2,165.44 3 0 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:165.44,169.24 2 0 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:169.24,174.4 1 0 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:177.2,177.12 1 0 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:186.27,193.32 3 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:193.32,197.3 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:199.2,199.27 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:199.27,201.3 1 0 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:203.2,203.42 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:212.10,213.31 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:213.31,214.44 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:214.44,216.4 1 1 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:218.3,218.18 1 0 +github.com/azure/azure-dev/cli/azd/pkg/tools/language/executor.go:221.2,221.18 1 0 diff --git a/cli/azd/docs/language-hooks.md b/cli/azd/docs/language-hooks.md new file mode 100644 index 00000000000..d0e226ffdbd --- /dev/null +++ b/cli/azd/docs/language-hooks.md @@ -0,0 +1,162 @@ +# Language Hooks + +Azure Developer CLI supports running hook scripts in programming languages beyond +shell scripts (Bash/PowerShell). Language hooks use a language-specific executor that +automatically handles dependency installation and runtime management. + +## Supported Languages + +| Language | `language` value | File extension | Status | +|------------|-----------------|----------------|--------------| +| Bash | `sh` | `.sh` | ✅ Stable | +| PowerShell | `pwsh` | `.ps1` | ✅ Stable | +| Python | `python` | `.py` | ✅ Phase 1 | +| JavaScript | `js` | `.js` | 🔜 Planned | +| TypeScript | `ts` | `.ts` | 🔜 Planned | +| .NET (C#) | `dotnet` | `.cs` | 🔜 Planned | + +## Configuration + +Language hooks are configured in `azure.yaml` under the `hooks` section at the +project or service level. Two new optional fields are available: + +### `language` (string, optional) + +Specifies the programming language of the hook script. Allowed values: +`sh`, `pwsh`, `js`, `ts`, `python`, `dotnet`. + +When omitted, the language is **auto-detected** from the file extension of the +`run` path. For example, `run: ./hooks/seed.py` automatically sets +`language: python`. + +### `dir` (string, optional) + +Specifies the working directory for language hook execution, used as the project +context for dependency installation (e.g. `pip install` from `requirements.txt`) +and builds. + +When omitted, **automatically inferred** from the directory containing the script +referenced by `run`. For example, `run: hooks/preprovision/main.py` sets the +working directory to `hooks/preprovision/`. Only set `dir` when the project root +differs from the script's directory (e.g. when the entry point lives in a `src/` +subdirectory). + +Relative paths are resolved from the project or service root. + +## Examples + +### Python hook — auto-detected from .py extension + +The simplest way to use a Python hook. The language is inferred from the `.py` +extension, and the working directory is auto-inferred from the script's location. +Dependencies are installed automatically if a `requirements.txt` or +`pyproject.toml` is found in the script's directory. + +```yaml +hooks: + postprovision: + run: ./hooks/seed-database.py +``` + +### Python hook in a subdirectory (dir auto-inferred) + +When the script lives in a subdirectory, the `dir` is automatically set to that +directory. No explicit `dir` field is needed: + +```yaml +hooks: + preprovision: + run: hooks/preprovision/main.py + # dir is auto-inferred as hooks/preprovision/ +``` + +### Python hook — explicit language + +When auto-detection is not desired or the file extension is ambiguous, set +the `language` field explicitly: + +```yaml +hooks: + postprovision: + run: ./hooks/setup.py + language: python +``` + +### Python hook with project directory override + +When the script's project root differs from the script's directory (e.g. the +entry point is in a `src/` subdirectory but dependencies are at the project +level), use `dir` to override the auto-inferred value: + +```yaml +hooks: + postprovision: + run: ./hooks/data-tool/src/main.py + language: python + dir: ./hooks/data-tool # override: project root differs from script location +``` + +### Python hook with platform overrides + +Use `windows` and `posix` overrides to provide platform-specific hooks: + +```yaml +hooks: + postprovision: + windows: + run: ./hooks/setup.ps1 + shell: pwsh + posix: + run: ./hooks/setup.py + language: python +``` + +### Python hook with secrets + +Language hooks support the same `secrets` field as shell hooks for +resolving Azure Key Vault references: + +```yaml +hooks: + postprovision: + run: ./hooks/seed-database.py + secrets: + DB_CONNECTION_STRING: DATABASE_URL +``` + +### Shell hook (existing behavior, unchanged) + +Shell hooks continue to work exactly as before. The `language` field is +optional and defaults to the shell type: + +```yaml +hooks: + preprovision: + run: echo "Provisioning starting..." + shell: sh +``` + +## How It Works + +When a language hook runs, the executor performs these steps: + +1. **Language Detection** — Determines the script language from the explicit + `language` field, the `shell` field, or the file extension. +2. **Runtime Validation** — Verifies the required runtime is installed + (e.g. Python 3 for `.py` hooks). +3. **Project Discovery** — Walks up the directory tree from the script to + find project files (`requirements.txt`, `pyproject.toml`, `package.json`, + `*.*proj`). The search stops at the project/service root boundary. +4. **Dependency Installation** — Creates a virtual environment (for Python) + and installs dependencies from the discovered project file. +5. **Script Execution** — Runs the script with the language runtime, using + the virtual environment if one was created. + +## Limitations + +- **Inline scripts** are only supported for shell hooks (`sh`, `pwsh`). + Language hooks must reference a file path. +- **Phase 1** supports only Python. JavaScript, TypeScript, and .NET support + is planned for future phases. +- **Virtual environments** are created in the project directory alongside the + dependency file, following the naming convention `{dirName}_env`. diff --git a/cli/azd/pkg/ext/hooks_manager.go b/cli/azd/pkg/ext/hooks_manager.go index 4d5e9a06558..d0fdb2c94a1 100644 --- a/cli/azd/pkg/ext/hooks_manager.go +++ b/cli/azd/pkg/ext/hooks_manager.go @@ -14,8 +14,11 @@ import ( "runtime" "strings" + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" "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/language" + "github.com/azure/azure-dev/cli/azd/pkg/tools/python" ) type HookFilterPredicateFn func(scriptName string, hookConfig *HookConfig) bool @@ -246,5 +249,144 @@ func (h *HooksManager) ValidateHooks(ctx context.Context, allHooks map[string][] log.Println(warningMessage) } + // Check language runtime availability for language hooks. + langWarnings := h.validateLanguageRuntimes(ctx, allHooks) + result.Warnings = append(result.Warnings, langWarnings...) + return result } + +// validateLanguageRuntimes inspects all hook configurations and +// verifies that required language runtimes are installed. It returns +// warnings for each missing runtime, following the same pattern used +// for the PowerShell 7 validation above. +// +// Currently only Python hooks are validated (Phase 1). JavaScript, +// TypeScript, and DotNet hooks are deferred to later phases. +func (h *HooksManager) validateLanguageRuntimes( + ctx context.Context, + allHooks map[string][]*HookConfig, +) []HookWarning { + var warnings []HookWarning + + // Collect unique language runtimes required across all hooks. + // Track the first hook name per language for actionable messages. + requiredLangs := map[language.ScriptLanguage]string{} + + for hookName, hookConfigs := range allHooks { + for _, hookConfig := range hookConfigs { + if hookConfig == nil { + continue + } + + // Apply OS-specific override if present. + cfg := hookConfig + if runtime.GOOS == "windows" && cfg.Windows != nil { + cfg = cfg.Windows + } else if (runtime.GOOS == "linux" || + runtime.GOOS == "darwin") && cfg.Posix != nil { + cfg = cfg.Posix + } + + if cfg.cwd == "" { + cfg.cwd = h.cwd + } + + // Set the hook name so that any temp scripts + // created by validate() use the correct name + // pattern (e.g. azd-predeploy-*.sh). + if cfg.Name == "" { + cfg.Name = hookName + } + + // Run validate to resolve the Language field from + // file extension / explicit config. + if err := cfg.validate(); err != nil { + // Validation errors are surfaced by GetAll / + // GetByParams; skip the hook here. + continue + } + + if cfg.IsLanguageHook() { + if _, seen := requiredLangs[cfg.Language]; !seen { + requiredLangs[cfg.Language] = hookName + } + } + } + } + + // Phase 1: validate Python runtime. + if hookName, ok := requiredLangs[language.ScriptLanguagePython]; ok { + pythonCli := python.NewCli(h.commandRunner) + if err := pythonCli.CheckInstalled(ctx); err != nil { + warnings = append(warnings, HookWarning{ + Message: fmt.Sprintf( + "Python 3 is required to run hook '%s' "+ + "but is not installed", + hookName, + ), + Suggestion: fmt.Sprintf( + "Install Python 3 from %s", + output.WithHyperlink( + pythonCli.InstallUrl(), + "Python Downloads", + ), + ), + }) + } + } + + // Phase 2: JS/TS — not yet validated. + // Phase 4: DotNet — not yet validated. + + return warnings +} + +// ValidateLanguageRuntimesErr is a convenience wrapper around +// ValidateHooks that returns an [errorhandler.ErrorWithSuggestion] +// when any language runtime is missing. Callers that need a hard +// early failure (e.g. before starting a long deployment) should use +// this instead of inspecting warnings manually. +func (h *HooksManager) ValidateLanguageRuntimesErr( + ctx context.Context, + allHooks map[string][]*HookConfig, +) error { + warnings := h.validateLanguageRuntimes(ctx, allHooks) + if len(warnings) == 0 { + return nil + } + + // Use the first warning for the primary error; additional + // warnings are appended as links. + first := warnings[0] + links := make([]errorhandler.ErrorLink, 0, len(warnings)) + for _, w := range warnings { + links = append(links, errorhandler.ErrorLink{ + URL: extractURL(w.Suggestion), + Title: w.Message, + }) + } + + return &errorhandler.ErrorWithSuggestion{ + Err: fmt.Errorf( + "missing required language runtime: %s", + first.Message, + ), + Message: first.Message, + Suggestion: first.Suggestion, + Links: links, + } +} + +// extractURL returns the first https:// URL found in s, or s itself +// if none is found. Used to pull install URLs out of formatted +// suggestion strings. +func extractURL(s string) string { + for part := range strings.FieldsSeq(s) { + if strings.HasPrefix(part, "https://") || + strings.HasPrefix(part, "http://") { + return strings.TrimRight(part, ")") + } + } + return s +} diff --git a/cli/azd/pkg/ext/hooks_manager_test.go b/cli/azd/pkg/ext/hooks_manager_test.go index 5be4b0f649b..d2273933a88 100644 --- a/cli/azd/pkg/ext/hooks_manager_test.go +++ b/cli/azd/pkg/ext/hooks_manager_test.go @@ -5,11 +5,13 @@ package ext import ( "os" + osexec "os/exec" "path/filepath" "regexp" "strings" "testing" + "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/test/mocks/mockexec" "github.com/azure/azure-dev/cli/azd/test/ostest" @@ -187,6 +189,205 @@ func Test_HookConfig_DefaultShell(t *testing.T) { } } +func Test_ValidateHooks_PythonInstalled(t *testing.T) { + tempDir := t.TempDir() + ostest.Chdir(t, tempDir) + + // Create a Python script file so validate() resolves it + // as a language hook. + scriptDir := filepath.Join(tempDir, "hooks") + require.NoError(t, os.MkdirAll(scriptDir, osutil.PermissionDirectory)) + require.NoError(t, + os.WriteFile( + filepath.Join(scriptDir, "setup.py"), + []byte("print('hello')"), osutil.PermissionExecutableFile, + ), + ) + + hooksMap := map[string][]*HookConfig{ + "preprovision": { + {Run: "hooks/setup.py"}, + }, + } + + mockRunner := mockexec.NewMockCommandRunner() + + // Mock Python as available: ToolInPath succeeds and + // --version returns a valid version. + mockRunner.MockToolInPath("py", nil) + mockRunner.When(func(args exec.RunArgs, cmd string) bool { + return strings.Contains(cmd, "--version") + }).Respond(exec.RunResult{Stdout: "Python 3.12.0"}) + + mgr := NewHooksManager(tempDir, mockRunner) + result := mgr.ValidateHooks(t.Context(), hooksMap) + + // No language-runtime warnings should be present. + for _, w := range result.Warnings { + require.NotContains(t, w.Message, "Python", + "expected no Python warning when runtime is installed") + } + + // Also verify the error-returning variant. + require.NoError(t, + mgr.ValidateLanguageRuntimesErr(t.Context(), hooksMap)) +} + +func Test_ValidateHooks_PythonNotInstalled(t *testing.T) { + tempDir := t.TempDir() + ostest.Chdir(t, tempDir) + + scriptDir := filepath.Join(tempDir, "hooks") + require.NoError(t, os.MkdirAll(scriptDir, osutil.PermissionDirectory)) + require.NoError(t, + os.WriteFile( + filepath.Join(scriptDir, "setup.py"), + []byte("print('hello')"), osutil.PermissionExecutableFile, + ), + ) + + hooksMap := map[string][]*HookConfig{ + "preprovision": { + {Run: "hooks/setup.py"}, + }, + } + + mockRunner := mockexec.NewMockCommandRunner() + + // Mock Python as NOT available on any platform path. + mockRunner.MockToolInPath("py", osexec.ErrNotFound) + mockRunner.MockToolInPath("python", osexec.ErrNotFound) + mockRunner.MockToolInPath("python3", osexec.ErrNotFound) + + mgr := NewHooksManager(tempDir, mockRunner) + result := mgr.ValidateHooks(t.Context(), hooksMap) + + // Expect a warning about missing Python. + require.NotEmpty(t, result.Warnings, + "expected at least one warning for missing Python") + + found := false + for _, w := range result.Warnings { + if strings.Contains(w.Message, "Python") { + found = true + require.Contains(t, w.Message, "preprovision") + require.Contains(t, w.Suggestion, "python") + break + } + } + require.True(t, found, "expected a Python-related warning") + + // Verify the error-returning variant surfaces an + // ErrorWithSuggestion. + err := mgr.ValidateLanguageRuntimesErr(t.Context(), hooksMap) + require.Error(t, err) + require.Contains(t, err.Error(), "Python") +} + +func Test_ValidateHooks_ShellHookNoValidation(t *testing.T) { + tempDir := t.TempDir() + ostest.Chdir(t, tempDir) + + // Create shell scripts only — no language hooks. + scriptDir := filepath.Join(tempDir, "scripts") + require.NoError(t, os.MkdirAll(scriptDir, osutil.PermissionDirectory)) + require.NoError(t, + os.WriteFile( + filepath.Join(scriptDir, "pre.sh"), + nil, osutil.PermissionExecutableFile, + ), + ) + require.NoError(t, + os.WriteFile( + filepath.Join(scriptDir, "post.ps1"), + nil, osutil.PermissionExecutableFile, + ), + ) + + hooksMap := map[string][]*HookConfig{ + "preprovision": { + {Run: "scripts/pre.sh"}, + }, + "postprovision": { + {Run: "scripts/post.ps1"}, + }, + } + + mockRunner := mockexec.NewMockCommandRunner() + // pwsh available so PowerShell warning doesn't fire. + mockRunner.MockToolInPath("pwsh", nil) + + mgr := NewHooksManager(tempDir, mockRunner) + result := mgr.ValidateHooks(t.Context(), hooksMap) + + // No language-runtime warnings for shell hooks. + for _, w := range result.Warnings { + require.NotContains(t, w.Message, "Python", + "shell-only hooks must not trigger language warnings") + } + + require.NoError(t, + mgr.ValidateLanguageRuntimesErr(t.Context(), hooksMap)) +} + +func Test_ValidateHooks_MixedHooks(t *testing.T) { + tempDir := t.TempDir() + ostest.Chdir(t, tempDir) + + // Create both shell and Python scripts. + require.NoError(t, + os.MkdirAll(filepath.Join(tempDir, "scripts"), osutil.PermissionDirectory)) + require.NoError(t, + os.WriteFile( + filepath.Join(tempDir, "scripts", "setup.sh"), + nil, osutil.PermissionExecutableFile, + ), + ) + require.NoError(t, + os.MkdirAll(filepath.Join(tempDir, "hooks"), osutil.PermissionDirectory)) + require.NoError(t, + os.WriteFile( + filepath.Join(tempDir, "hooks", "migrate.py"), + []byte("print('migrate')"), osutil.PermissionExecutableFile, + ), + ) + + hooksMap := map[string][]*HookConfig{ + "preprovision": { + {Run: "scripts/setup.sh"}, + }, + "postprovision": { + {Run: "hooks/migrate.py"}, + }, + } + + mockRunner := mockexec.NewMockCommandRunner() + // Python NOT available. + mockRunner.MockToolInPath("py", osexec.ErrNotFound) + mockRunner.MockToolInPath("python", osexec.ErrNotFound) + mockRunner.MockToolInPath("python3", osexec.ErrNotFound) + // pwsh available — no PowerShell warning. + mockRunner.MockToolInPath("pwsh", nil) + + mgr := NewHooksManager(tempDir, mockRunner) + result := mgr.ValidateHooks(t.Context(), hooksMap) + + // Exactly one language warning (Python), no shell warnings. + pythonWarnings := 0 + for _, w := range result.Warnings { + if strings.Contains(w.Message, "Python") { + pythonWarnings++ + require.Contains(t, w.Message, "postprovision") + } + } + require.Equal(t, 1, pythonWarnings, + "expected exactly one Python warning for mixed hooks") + + err := mgr.ValidateLanguageRuntimesErr(t.Context(), hooksMap) + require.Error(t, err) + require.Contains(t, err.Error(), "Python") +} + func ensureScriptsExist(t *testing.T, configs map[string][]*HookConfig) { for _, hooks := range configs { for _, hook := range hooks { diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index 66448a581c6..9d1283b0620 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -8,17 +8,17 @@ import ( "fmt" "log" "os" + "path/filepath" "strings" "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" "github.com/azure/azure-dev/cli/azd/pkg/exec" "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/keyvault" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/tools" - "github.com/azure/azure-dev/cli/azd/pkg/tools/bash" - "github.com/azure/azure-dev/cli/azd/pkg/tools/powershell" ) // Hooks enable support to invoke integration scripts before & after commands @@ -91,7 +91,7 @@ func (h *HooksRunner) Invoke(ctx context.Context, commands []string, actionFn In func (h *HooksRunner) RunHooks( ctx context.Context, hookType HookType, - options *tools.ExecOptions, + options *tools.ExecutionContext, commands ...string, ) error { hooks, err := h.hooksManager.GetByParams(h.hooks, hookType, commands...) @@ -117,29 +117,11 @@ func (h *HooksRunner) RunHooks( return nil } -// Gets the script to execute based on the hook configuration values -// For inline scripts this will also create a temporary script file to execute -func (h *HooksRunner) GetScript(hookConfig *HookConfig, envVars []string) (tools.Script, error) { - if err := hookConfig.validate(); err != nil { - return nil, err - } - - switch ShellType(strings.Split(string(hookConfig.Shell), " ")[0]) { - case ShellTypeBash: - return bash.NewBashScript(h.commandRunner, h.cwd, envVars), nil - case ShellTypePowershell: - return powershell.NewPowershellScript(h.commandRunner, h.cwd, envVars), nil - default: - return nil, fmt.Errorf( - "shell type '%s' is not a valid option. Only 'sh' and 'pwsh' are supported", - hookConfig.Shell, - ) - } -} - -func (h *HooksRunner) execHook(ctx context.Context, hookConfig *HookConfig, options *tools.ExecOptions) error { +func (h *HooksRunner) execHook( + ctx context.Context, hookConfig *HookConfig, options *tools.ExecutionContext, +) error { if options == nil { - options = &tools.ExecOptions{} + options = &tools.ExecutionContext{} } hookEnv := environment.NewWithValues("temp", h.env.Dotenv()) @@ -166,61 +148,201 @@ func (h *HooksRunner) execHook(ctx context.Context, hookConfig *HookConfig, opti } } - script, err := h.GetScript(hookConfig, hookEnv.Environ()) - if err != nil { + // validate() resolves the hook's language, path, and shell type. + if err := hookConfig.validate(); err != nil { return err } - formatter := h.console.GetFormatter() - consoleInteractive := (formatter == nil || formatter.Kind() == output.NoneFormat) - scriptInteractive := consoleInteractive && hookConfig.Interactive + // Determine the boundary directory for project file discovery. + boundaryDir := h.cwd + if hookConfig.cwd != "" { + boundaryDir = hookConfig.cwd + } + + // Determine working directory from Dir (set explicitly or + // auto-inferred from the run path by validate). + cwd := h.cwd + if hookConfig.Dir != "" { + dir := hookConfig.Dir + if !filepath.IsAbs(dir) { + dir = filepath.Join(boundaryDir, dir) + } + cwd = dir + } else if hookConfig.path != "" && hookConfig.IsLanguageHook() { + cwd = filepath.Dir( + filepath.Join(boundaryDir, hookConfig.path), + ) + } - if options.Interactive == nil { - options.Interactive = &scriptInteractive + envVars := hookEnv.Environ() + + // Build execution context. + execCtx := tools.ExecutionContext{ + Cwd: cwd, + EnvVars: envVars, + BoundaryDir: boundaryDir, } - // When the hook is not configured to run in interactive mode and no stdout has been configured - // Then show the hook execution output within the console previewer pane - if !*options.Interactive && options.StdOut == nil { - previewer := h.console.ShowPreviewer(ctx, &input.ShowPreviewerOptions{ - Prefix: " ", - Title: fmt.Sprintf("%s Hook Output", hookConfig.Name), - MaxLineCount: 8, - }) - options.StdOut = previewer + // Merge caller-provided overrides (e.g. forced interactive from 'azd hooks run'). + if options.Interactive != nil { + execCtx.Interactive = options.Interactive + } + if options.StdOut != nil { + execCtx.StdOut = options.StdOut + } + + // Resolve executor via IoC — hooks runner has NO knowledge of executor internals. + var executor tools.HookExecutor + if err := h.serviceLocator.ResolveNamed(string(hookConfig.Language), &executor); err != nil { + return &errorhandler.ErrorWithSuggestion{ + Err: fmt.Errorf( + "no executor for language '%s': %w", + hookConfig.Language, err, + ), + Message: fmt.Sprintf( + "The '%s' language is not supported for hook '%s'.", + hookConfig.Language, + hookConfig.Name, + ), + Suggestion: "Supported hook languages: sh, pwsh, python.", + } + } + + // Resolve script path. Language hooks need the full path so + // Prepare can discover project files; shell hooks keep the + // relative path because the executor's CWD handles resolution. + scriptPath := hookConfig.path + if hookConfig.cwd != "" && hookConfig.IsLanguageHook() { + scriptPath = filepath.Join(hookConfig.cwd, hookConfig.path) + } + + // Prepare (unified — venv/deps for Python, pwsh detection for PS, no-op for bash). + log.Printf( + "Preparing hook '%s' (%s)\n", + hookConfig.Name, hookConfig.Language, + ) + + if err := executor.Prepare(ctx, scriptPath, execCtx); err != nil { + return &errorhandler.ErrorWithSuggestion{ + Err: fmt.Errorf( + "preparing %s hook '%s': %w", + hookConfig.Language, + hookConfig.Name, + err, + ), + Message: fmt.Sprintf( + "Failed to prepare %s hook '%s'.", + hookConfig.Language, + hookConfig.Name, + ), + Suggestion: fmt.Sprintf( + "Ensure the required runtime for '%s' is installed.", + hookConfig.Language, + ), + } + } + + // Configure console/previewer. + if h.configureExecContext(ctx, hookConfig, &execCtx) { defer h.console.StopPreviewer(ctx, false) } - options.UserPwsh = string(hookConfig.Shell) - log.Printf("Executing script '%s'\n", hookConfig.path) - res, err := script.Execute(ctx, hookConfig.path, *options) + // Execute (unified). + log.Printf( + "Executing hook '%s' (%s)\n", + hookConfig.Name, scriptPath, + ) + + res, err := executor.Execute(ctx, scriptPath, execCtx) if err != nil { - execErr := fmt.Errorf( - "'%s' hook failed with exit code: '%d', Path: '%s'. : %w", - hookConfig.Name, - res.ExitCode, - hookConfig.path, - err, + hookErr := h.handleHookError( + ctx, hookConfig, res, scriptPath, err, ) - - // If an error occurred log the failure but continue - if hookConfig.ContinueOnError { - h.console.Message(ctx, output.WithBold("%s", output.WithWarningFormat("WARNING: %s", execErr.Error()))) - h.console.Message( - ctx, - output.WithWarningFormat("Execution will continue since ContinueOnError has been set to true."), - ) - log.Println(execErr.Error()) - } else { - return execErr + if hookErr != nil { + return hookErr } } - // Delete any temporary inline scripts after execution - // Removing temp scripts only on success to support better debugging with failing scripts. + // Cleanup inline temp scripts. if hookConfig.location == ScriptLocationInline { defer os.Remove(hookConfig.path) } return nil } + +// configureExecContext resolves interactive mode and sets up the +// console previewer for non-interactive hooks that have no custom +// stdout. This logic is shared by both shell and language hooks. +// Returns true when a previewer was started; the caller must defer +// [input.Console.StopPreviewer] in that case. +func (h *HooksRunner) configureExecContext( + ctx context.Context, + hookConfig *HookConfig, + execCtx *tools.ExecutionContext, +) bool { + formatter := h.console.GetFormatter() + consoleInteractive := (formatter == nil || + formatter.Kind() == output.NoneFormat) + scriptInteractive := consoleInteractive && hookConfig.Interactive + + if execCtx.Interactive == nil { + execCtx.Interactive = &scriptInteractive + } + + // When the hook is not configured to run in interactive mode + // and no stdout has been configured, show the hook execution + // output within the console previewer pane. + if !*execCtx.Interactive && execCtx.StdOut == nil { + previewer := h.console.ShowPreviewer( + ctx, + &input.ShowPreviewerOptions{ + Prefix: " ", + Title: fmt.Sprintf("%s Hook Output", hookConfig.Name), + MaxLineCount: 8, + }, + ) + execCtx.StdOut = previewer + return true + } + + return false +} + +// handleHookError wraps a hook execution error and either returns +// it or logs a warning when ContinueOnError is set. +func (h *HooksRunner) handleHookError( + ctx context.Context, + hookConfig *HookConfig, + res exec.RunResult, + scriptPath string, + err error, +) error { + execErr := fmt.Errorf( + "'%s' hook failed with exit code: '%d', Path: '%s'. : %w", + hookConfig.Name, + res.ExitCode, + scriptPath, + err, + ) + + if hookConfig.ContinueOnError { + h.console.Message( + ctx, + output.WithBold( + "%s", + output.WithWarningFormat("WARNING: %s", execErr.Error()), + ), + ) + h.console.Message( + ctx, + output.WithWarningFormat( + "Execution will continue since ContinueOnError has been set to true.", + ), + ) + log.Println(execErr.Error()) + return nil + } + + return execErr +} diff --git a/cli/azd/pkg/ext/hooks_runner_test.go b/cli/azd/pkg/ext/hooks_runner_test.go index 3db41883bb6..99e26191883 100644 --- a/cli/azd/pkg/ext/hooks_runner_test.go +++ b/cli/azd/pkg/ext/hooks_runner_test.go @@ -5,14 +5,19 @@ package ext import ( "context" + "fmt" "os" - "reflect" + "path/filepath" "strings" "testing" "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/osutil" + "github.com/azure/azure-dev/cli/azd/pkg/tools/bash" + "github.com/azure/azure-dev/cli/azd/pkg/tools/language" + "github.com/azure/azure-dev/cli/azd/pkg/tools/powershell" + "github.com/azure/azure-dev/cli/azd/pkg/tools/python" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/azure/azure-dev/cli/azd/test/mocks/mockenv" "github.com/azure/azure-dev/cli/azd/test/ostest" @@ -20,6 +25,25 @@ import ( "github.com/stretchr/testify/require" ) +// registerHookExecutors registers all hook executors as named +// transients in the mock container so that IoC resolution works +// in tests. +func registerHookExecutors(mockCtx *mocks.MockContext) { + mockCtx.Container.MustRegisterNamedTransient( + string(language.ScriptLanguageBash), bash.NewExecutor, + ) + mockCtx.Container.MustRegisterNamedTransient( + string(language.ScriptLanguagePowerShell), powershell.NewExecutor, + ) + + // Register python.Cli (needed by NewPythonExecutor IoC constructor). + mockCtx.Container.MustRegisterSingleton(python.NewCli) + + mockCtx.Container.MustRegisterNamedTransient( + string(language.ScriptLanguagePython), language.NewPythonExecutor, + ) +} + func Test_Hooks_Execute(t *testing.T) { cwd := t.TempDir() ostest.Chdir(t, cwd) @@ -69,6 +93,7 @@ func Test_Hooks_Execute(t *testing.T) { ranPostHook := false mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { return strings.Contains(command, "precommand.sh") }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { @@ -104,6 +129,7 @@ func Test_Hooks_Execute(t *testing.T) { ranPostHook := false mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { return strings.Contains(command, "postcommand.sh") }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { @@ -139,6 +165,7 @@ func Test_Hooks_Execute(t *testing.T) { ranPostHook := false mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { return strings.Contains(command, "preinteractive.sh") }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { @@ -174,6 +201,7 @@ func Test_Hooks_Execute(t *testing.T) { ranPostHook := false mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { return strings.Contains(command, "preinline") }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { @@ -208,6 +236,7 @@ func Test_Hooks_Execute(t *testing.T) { hookLog := []string{} mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { return strings.Contains(command, "precommand.sh") }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { @@ -260,7 +289,10 @@ func Test_Hooks_Execute(t *testing.T) { }) } -func Test_Hooks_GetScript(t *testing.T) { +// Test_Hooks_Validation verifies that hook configuration validation +// works correctly for all supported script types through the unified +// execHook path. +func Test_Hooks_Validation(t *testing.T) { cwd := t.TempDir() ostest.Chdir(t, cwd) @@ -272,38 +304,184 @@ func Test_Hooks_GetScript(t *testing.T) { }, ) - hooksMap := map[string][]*HookConfig{ - "bash": { - { - Run: "scripts/script.sh", - }, - }, - "pwsh": { - { - Run: "scripts/script.ps1", - }, - }, - "inline": { - { + // Create script files on disk for validation. + require.NoError(t, os.MkdirAll(filepath.Join(cwd, "scripts"), osutil.PermissionDirectory)) + require.NoError(t, os.WriteFile( + filepath.Join(cwd, "scripts", "script.sh"), nil, osutil.PermissionExecutableFile, + )) + require.NoError(t, os.WriteFile( + filepath.Join(cwd, "scripts", "script.ps1"), nil, osutil.PermissionExecutableFile, + )) + + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) + + t.Run("BashHookExecutes", func(t *testing.T) { + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", Shell: ShellTypeBash, - Run: "echo 'hello'", - }, - }, - "inlineWithUrl": { - { - Shell: ShellTypePowershell, - Run: "Invoke-WebRequest -Uri \"https://sample.com/sample.json\" -OutFile \"out.json\"", + Run: "scripts/script.sh", + }}, + } + + shellRan := false + mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "script.sh") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + shellRan = true + return exec.NewRunResult(0, "", ""), nil + }) + + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) + runner := NewHooksRunner( + hooksManager, mockContext.CommandRunner, envManager, + mockContext.Console, cwd, hooksMap, env, mockContext.Container, + ) + + err := runner.RunHooks(*mockContext.Context, HookTypePre, nil, "deploy") + require.NoError(t, err) + require.True(t, shellRan) + }) + + t.Run("PowershellHookExecutes", func(t *testing.T) { + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: "scripts/script.ps1", + }}, + } + + shellRan := false + mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + mockContext.CommandRunner.MockToolInPath("pwsh", nil) + + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "script.ps1") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + shellRan = true + require.Equal(t, "pwsh", args.Cmd) + return exec.NewRunResult(0, "", ""), nil + }) + + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) + runner := NewHooksRunner( + hooksManager, mockContext.CommandRunner, envManager, + mockContext.Console, cwd, hooksMap, env, mockContext.Container, + ) + + err := runner.RunHooks(*mockContext.Context, HookTypePre, nil, "deploy") + require.NoError(t, err) + require.True(t, shellRan) + }) + + t.Run("InlineBashHookExecutes", func(t *testing.T) { + hooksMap := map[string][]*HookConfig{ + "preinline": {{ + Name: "preinline", + Shell: ShellTypeBash, + Run: "echo 'Hello'", + }}, + } + + inlineRan := false + mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "preinline") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + inlineRan = true + return exec.NewRunResult(0, "", ""), nil + }) + + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) + runner := NewHooksRunner( + hooksManager, mockContext.CommandRunner, envManager, + mockContext.Console, cwd, hooksMap, env, mockContext.Container, + ) + + err := runner.RunHooks(*mockContext.Context, HookTypePre, nil, "inline") + require.NoError(t, err) + require.True(t, inlineRan) + }) + + t.Run("MissingRunReturnsError", func(t *testing.T) { + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Shell: ShellTypeBash, + }}, + } + + mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) + runner := NewHooksRunner( + hooksManager, mockContext.CommandRunner, envManager, + mockContext.Console, cwd, hooksMap, env, mockContext.Container, + ) + + err := runner.RunHooks(*mockContext.Context, HookTypePre, nil, "deploy") + require.Error(t, err) + require.ErrorIs(t, err, ErrRunRequired) + }) +} + +// Test_ExecHook_LanguageHooks verifies the integration between +// [HooksRunner] and [tools.HookExecutor] for non-shell hooks. +func Test_ExecHook_LanguageHooks(t *testing.T) { + t.Run("PythonLanguageHook", func(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + env := environment.NewWithValues("test", map[string]string{ + "FOO": "bar", + }) + + // Create a .py script file on disk so validate() sees it. + scriptDir := filepath.Join(cwd, "hooks") + require.NoError(t, os.MkdirAll(scriptDir, osutil.PermissionDirectory)) + scriptFile := filepath.Join(scriptDir, "predeploy.py") + require.NoError(t, os.WriteFile(scriptFile, nil, osutil.PermissionExecutableFile)) + + hooksMap := map[string][]*HookConfig{ + "predeploy": { + { + Name: "predeploy", + Run: filepath.Join("hooks", "predeploy.py"), + }, }, - }, - } + } - ensureScriptsExist(t, hooksMap) + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) - envManager := &mockenv.MockEnvManager{} + prepareRan := false + executeRan := false - t.Run("Bash", func(t *testing.T) { - hookConfig := hooksMap["bash"][0] mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + + // Mock the Python version check issued by python.Cli.CheckInstalled + // via tools.ExecuteCommand → commandRunner.Run. + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "--version") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + prepareRan = true + return exec.NewRunResult(0, "Python 3.11.0", ""), nil + }) + + // Mock the actual Python script execution. + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + executeRan = true + return exec.NewRunResult(0, "", ""), nil + }) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, @@ -316,17 +494,48 @@ func Test_Hooks_GetScript(t *testing.T) { mockContext.Container, ) - script, err := runner.GetScript(hookConfig, runner.env.Environ()) - require.NotNil(t, script) - require.Equal(t, "*bash.bashScript", reflect.TypeOf(script).String()) - require.Equal(t, ScriptLocationPath, hookConfig.location) - require.Equal(t, ShellTypeBash, hookConfig.Shell) + err := runner.RunHooks( + *mockContext.Context, HookTypePre, nil, "deploy", + ) + require.NoError(t, err) + require.True(t, prepareRan, "Prepare (version check) should have run") + require.True(t, executeRan, "Execute should have run the .py script") }) - t.Run("Powershell", func(t *testing.T) { - hookConfig := hooksMap["pwsh"][0] + t.Run("ShellHookUnchanged", func(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + env := environment.NewWithValues("test", map[string]string{}) + + hooksMap := map[string][]*HookConfig{ + "predeploy": { + { + Name: "predeploy", + Shell: ShellTypeBash, + Run: "scripts/predeploy.sh", + }, + }, + } + ensureScriptsExist(t, hooksMap) + + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) + + shellRan := false + mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "predeploy.sh") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + shellRan = true + require.Equal(t, "scripts/predeploy.sh", args.Args[0]) + require.Equal(t, cwd, args.Cwd) + return exec.NewRunResult(0, "", ""), nil + }) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, @@ -339,20 +548,50 @@ func Test_Hooks_GetScript(t *testing.T) { mockContext.Container, ) - script, err := runner.GetScript(hookConfig, runner.env.Environ()) - require.NotNil(t, script) - require.Equal(t, "*powershell.powershellScript", reflect.TypeOf(script).String()) - require.Equal(t, ScriptLocationPath, hookConfig.location) - require.Equal(t, ShellTypePowershell, hookConfig.Shell) + err := runner.RunHooks( + *mockContext.Context, HookTypePre, nil, "deploy", + ) + require.NoError(t, err) + require.True(t, shellRan, "Shell script path should be used for .sh hooks") }) - t.Run("Inline Script", func(t *testing.T) { - tempDir := t.TempDir() - ostest.Chdir(t, tempDir) + t.Run("LanguageHookPrepareFailure", func(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + env := environment.NewWithValues("test", map[string]string{}) + + scriptDir := filepath.Join(cwd, "hooks") + require.NoError(t, os.MkdirAll(scriptDir, osutil.PermissionDirectory)) + scriptFile := filepath.Join(scriptDir, "predeploy.py") + require.NoError(t, os.WriteFile( + scriptFile, nil, osutil.PermissionExecutableFile, + )) + + hooksMap := map[string][]*HookConfig{ + "predeploy": { + { + Name: "predeploy", + Run: filepath.Join("hooks", "predeploy.py"), + }, + }, + } + + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) - hookConfig := hooksMap["inline"][0] mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + + // Simulate Python not being installed — version check + // fails with an error. + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "--version") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult(1, "", ""), fmt.Errorf("python not found") + }) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, @@ -365,26 +604,56 @@ func Test_Hooks_GetScript(t *testing.T) { mockContext.Container, ) - script, err := runner.GetScript(hookConfig, runner.env.Environ()) - require.NotNil(t, script) - require.Equal(t, "*bash.bashScript", reflect.TypeOf(script).String()) - require.Equal(t, ScriptLocationInline, hookConfig.location) - require.Equal(t, ShellTypeBash, hookConfig.Shell) - require.Contains(t, hookConfig.path, os.TempDir()) - require.Contains(t, hookConfig.path, ".sh") - require.NoError(t, err) + err := runner.RunHooks( + *mockContext.Context, HookTypePre, nil, "deploy", + ) - fileInfo, err := os.Stat(hookConfig.path) - require.NotNil(t, fileInfo) - require.NoError(t, err) + require.Error(t, err) + require.Contains(t, err.Error(), "preparing python hook") }) - t.Run("Inline With Url", func(t *testing.T) { - tempDir := t.TempDir() - ostest.Chdir(t, tempDir) + t.Run("LanguageHookExecuteFailure", func(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + env := environment.NewWithValues("test", map[string]string{}) + + scriptDir := filepath.Join(cwd, "hooks") + require.NoError(t, os.MkdirAll(scriptDir, osutil.PermissionDirectory)) + scriptFile := filepath.Join(scriptDir, "predeploy.py") + require.NoError(t, os.WriteFile( + scriptFile, nil, osutil.PermissionExecutableFile, + )) + + hooksMap := map[string][]*HookConfig{ + "predeploy": { + { + Name: "predeploy", + Run: filepath.Join("hooks", "predeploy.py"), + }, + }, + } + + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) - hookConfig := hooksMap["inlineWithUrl"][0] mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + + // Prepare succeeds (version check passes). + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "--version") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult(0, "Python 3.11.0", ""), nil + }) + + // Execute fails (script returns non-zero exit code). + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult(1, "", "error"), fmt.Errorf("script failed") + }) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, @@ -397,120 +666,98 @@ func Test_Hooks_GetScript(t *testing.T) { mockContext.Container, ) - script, err := runner.GetScript(hookConfig, runner.env.Environ()) - require.NotNil(t, script) - require.Equal(t, "*powershell.powershellScript", reflect.TypeOf(script).String()) - require.Equal(t, ScriptLocationInline, hookConfig.location) - require.Equal(t, ShellTypePowershell, hookConfig.Shell) - require.Contains( - t, - hookConfig.script, - "Invoke-WebRequest -Uri \"https://sample.com/sample.json\" -OutFile \"out.json\"", + err := runner.RunHooks( + *mockContext.Context, HookTypePre, nil, "deploy", ) - require.Contains(t, hookConfig.path, os.TempDir()) - require.Contains(t, hookConfig.path, ".ps1") - require.NoError(t, err) - fileInfo, err := os.Stat(hookConfig.path) - require.NotNil(t, fileInfo) - require.NoError(t, err) + require.Error(t, err) + require.Contains(t, err.Error(), "'predeploy' hook failed") + require.Contains(t, err.Error(), "exit code: '1'") }) -} + t.Run("LanguageHookEnvVars", func(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) -type scriptValidationTest struct { - name string - config *HookConfig - expectedError error - createFile bool -} - -func Test_GetScript_Validation(t *testing.T) { - tempDir := t.TempDir() - ostest.Chdir(t, tempDir) + env := environment.NewWithValues("test", map[string]string{ + "MY_VAR": "my_value", + "OTHER_VAR": "other_value", + }) - err := os.WriteFile("my-script.ps1", nil, osutil.PermissionFile) - require.NoError(t, err) + scriptDir := filepath.Join(cwd, "hooks") + require.NoError(t, os.MkdirAll(scriptDir, osutil.PermissionDirectory)) + scriptFile := filepath.Join(scriptDir, "predeploy.py") + require.NoError(t, os.WriteFile( + scriptFile, nil, osutil.PermissionExecutableFile, + )) + + hooksMap := map[string][]*HookConfig{ + "predeploy": { + { + Name: "predeploy", + Run: filepath.Join("hooks", "predeploy.py"), + }, + }, + } - env := environment.New("test") - envManager := &mockenv.MockEnvManager{} + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) - mockContext := mocks.NewMockContext(context.Background()) - hooksManager := NewHooksManager(tempDir, mockContext.CommandRunner) - runner := NewHooksRunner( - hooksManager, - mockContext.CommandRunner, - envManager, - mockContext.Console, - tempDir, - map[string][]*HookConfig{}, - env, - mockContext.Container, - ) + var capturedEnv []string - scriptValidations := []scriptValidationTest{ - { - name: "Missing Script Type - Should Use Default Shell", - config: &HookConfig{ - Name: "test1", - Run: "echo 'Hello'", - }, - expectedError: nil, // Should no longer error, should use default shell - }, - { - name: "Missing Run param", - config: &HookConfig{ - Name: "test2", - Shell: ShellTypeBash, - }, - expectedError: ErrRunRequired, - }, - { - name: "Unsupported Script Type", - config: &HookConfig{ - Name: "test4", - Run: "my-script.go", - }, - expectedError: ErrUnsupportedScriptType, - createFile: true, - }, - { - name: "Valid External Script", - config: &HookConfig{ - Name: "test5", - Run: "my-script.ps1", - }, - createFile: true, - }, - { - name: "Valid Inline", - config: &HookConfig{ - Name: "test5", - Shell: ShellTypeBash, - Run: "echo 'Hello'", - }, - }, - } + mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) - for _, test := range scriptValidations { - if test.createFile { - ensureScriptsExist( - t, - map[string][]*HookConfig{ - "test": {test.config}, - }, - ) - } + // Allow version check to pass. + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "--version") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult(0, "Python 3.11.0", ""), nil + }) - t.Run(test.name, func(t *testing.T) { - res, err := runner.GetScript(test.config, runner.env.Environ()) - if test.expectedError != nil { - require.Nil(t, res) - require.ErrorIs(t, err, test.expectedError) - } else { - require.NotNil(t, res) - require.NoError(t, err) - } + // Capture environment variables passed to execution. + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + capturedEnv = args.Env + return exec.NewRunResult(0, "", ""), nil }) + + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) + runner := NewHooksRunner( + hooksManager, + mockContext.CommandRunner, + envManager, + mockContext.Console, + cwd, + hooksMap, + env, + mockContext.Container, + ) + + err := runner.RunHooks( + *mockContext.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + require.NotEmpty(t, capturedEnv) + + // The environment variables from the hook's env should + // be forwarded to the language executor. + envMap := envSliceToMap(capturedEnv) + require.Equal(t, "my_value", envMap["MY_VAR"]) + require.Equal(t, "other_value", envMap["OTHER_VAR"]) + }) +} + +// envSliceToMap converts a KEY=VALUE environment slice to a map. +func envSliceToMap(envVars []string) map[string]string { + m := make(map[string]string, len(envVars)) + for _, entry := range envVars { + parts := strings.SplitN(entry, "=", 2) + if len(parts) == 2 { + m[parts[0]] = parts[1] + } } + return m } diff --git a/cli/azd/pkg/ext/models.go b/cli/azd/pkg/ext/models.go index 91a12b96495..c2123a2b72f 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -12,12 +12,22 @@ import ( "strings" "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "github.com/azure/azure-dev/cli/azd/pkg/tools/language" ) -// The type of hooks. Supported values are 'pre' and 'post' +// HookType represents the execution timing of a hook relative to the +// associated command. Supported values are 'pre' and 'post'. type HookType string + +// HookPlatformType identifies the operating system platform for +// platform-specific hook overrides. type HookPlatformType string + +// ShellType identifies the shell used to execute hook scripts. type ShellType string + +// ScriptLocation indicates whether a hook script is defined inline +// in azure.yaml or references an external file path. type ScriptLocation string const ( @@ -37,17 +47,34 @@ const ( ) var ( + // ErrScriptTypeUnknown indicates the shell type could not be inferred from + // the script path and was not explicitly configured. ErrScriptTypeUnknown error = errors.New( - "unable to determine script type. Ensure 'Shell' parameter is set in configuration options", + "unable to determine script type. " + + "Ensure 'shell' is set to 'sh' or 'pwsh' in your hook configuration, " + + "or use a file with a .sh or .ps1 extension", + ) + // ErrRunRequired indicates the hook configuration is missing the mandatory 'run' field. + ErrRunRequired error = errors.New( + "'run' is required for every hook configuration. " + + "Set 'run' to an inline script or a relative file path", + ) + // ErrUnsupportedScriptType indicates the script file has an extension that is not + // a recognized shell type (.sh or .ps1) and no explicit language or shell was set. + ErrUnsupportedScriptType error = errors.New( + "script type is not valid. Only '.sh' and '.ps1' are supported for shell hooks. " + + "For other languages, set the 'language' field (e.g. language: python)", ) - ErrRunRequired error = errors.New("run is always required") - ErrUnsupportedScriptType error = errors.New("script type is not valid. Only '.sh' and '.ps1' are supported") ) // Generic action function that may return an error type InvokeFn func() error -// Azd hook configuration +// HookConfig defines the configuration for a single hook in azure.yaml. +// Hooks are lifecycle scripts that run before or after azd commands. +// They may be shell scripts (sh/pwsh) executed via the shell runner, +// or programming-language scripts (Python, JS, TS, DotNet) executed +// via the [tools.HookExecutor] pipeline. type HookConfig struct { // The location of the script hook (file path or inline) location ScriptLocation @@ -66,6 +93,20 @@ type HookConfig struct { Name string `yaml:",omitempty"` // The type of script hook (bash or powershell) Shell ShellType `yaml:"shell,omitempty"` + // Language specifies the programming language of the hook script. + // Allowed values: "sh", "pwsh", "js", "ts", "python", "dotnet". + // When empty, the language is auto-detected from the file extension + // of the run path (e.g. .py → python, .ps1 → pwsh). If both + // Language and Shell are empty and run references a file, the + // extension is used. For inline scripts, Shell or Language must be + // set explicitly. + Language language.ScriptLanguage `yaml:"language,omitempty" json:"language,omitempty"` + // Dir specifies the working directory for language hook execution, + // used as the project context for dependency installation and builds. + // When empty, defaults to the directory containing the script + // referenced by the run field. Only set this when the project root + // differs from the script's directory. + Dir string `yaml:"dir,omitempty" json:"dir,omitempty"` // The inline script to execute or path to existing file Run string `yaml:"run,omitempty"` // When set to true will not halt command execution even when a script error occurs. @@ -81,7 +122,11 @@ type HookConfig struct { Secrets map[string]string `yaml:"secrets,omitempty"` } -// Validates and normalizes the hook configuration +// validate normalizes and validates the hook configuration. It resolves +// the script location (inline vs. file path), infers the Language from +// the Shell or file extension when not explicitly set, and rejects +// invalid combinations (e.g. inline scripts for non-shell languages). +// After a successful call, the hook is ready for execution. func (hc *HookConfig) validate() error { if hc.validated { return nil @@ -106,6 +151,52 @@ func (hc *HookConfig) validate() error { hc.script = hc.Run } + // Language resolution — priority: + // 1. explicit Language 2. explicit Shell 3. file extension + if hc.Language == language.ScriptLanguageUnknown { + switch { + case hc.Shell != ScriptTypeUnknown: + hc.Language = shellToLanguage(hc.Shell) + case hc.location == ScriptLocationPath: + hc.Language = language.InferLanguageFromPath(hc.Run) + } + } + + // Reject inline scripts for non-shell (language) hooks. + if hc.location == ScriptLocationInline && hc.IsLanguageHook() { + return fmt.Errorf( + "inline scripts are not supported for %s hooks. "+ + "Write your script to a file and set 'run' to the file path "+ + "(e.g. run: ./hooks/my-script.py)", + hc.Language, + ) + } + + // Language hooks are executed by a language-specific executor; + // no shell type resolution or temp script is needed. + if hc.IsLanguageHook() { + // Auto-infer Dir from the script's directory when not + // explicitly set by the user. + if hc.Dir == "" && hc.location == ScriptLocationPath { + hc.Dir = filepath.Dir(hc.path) + } + hc.validated = true + return nil + } + + // If Language resolved to a shell variant but Shell is unset, + // derive Shell so the existing shell execution path works. + if hc.Shell == ScriptTypeUnknown { + switch hc.Language { + case language.ScriptLanguageBash: + hc.Shell = ShellTypeBash + case language.ScriptLanguagePowerShell: + hc.Shell = ShellTypePowershell + } + } + + // --- existing shell behavior (unchanged) --- + // If shell is not specified and it's an inline script, use OS default if hc.Shell == ScriptTypeUnknown && hc.path == "" { hc.Shell = getDefaultShellForOS() @@ -138,6 +229,11 @@ func (hc *HookConfig) validate() error { hc.Shell = scriptType } + // Backfill Language from resolved Shell for shell-based hooks. + if hc.Language == language.ScriptLanguageUnknown { + hc.Language = shellToLanguage(hc.Shell) + } + hc.validated = true return nil @@ -174,6 +270,25 @@ func (hc *HookConfig) IsUsingDefaultShell() bool { return hc.usingDefaultShell } +// IsLanguageHook returns true when this hook targets a programming +// language (Python, JavaScript, TypeScript, or DotNet) rather than a +// shell (Bash or PowerShell). Language hooks are executed through the +// [tools.HookExecutor] pipeline instead of the shell runner. +func (hc *HookConfig) IsLanguageHook() bool { + switch hc.Language { + case language.ScriptLanguagePython, + language.ScriptLanguageJavaScript, + language.ScriptLanguageTypeScript, + language.ScriptLanguageDotNet: + return true + default: + return false + } +} + +// InferHookType extracts the hook timing prefix ("pre" or "post") +// from a hook name and returns the remaining command name. For +// example, "preprovision" → (HookTypePre, "provision"). func InferHookType(name string) (HookType, string) { // Validate name length so go doesn't PANIC for string slicing below if len(name) < 4 { @@ -195,6 +310,23 @@ func getDefaultShellForOS() ShellType { return ShellTypeBash } +// shellToLanguage maps a [ShellType] to the corresponding +// [language.ScriptLanguage]. Returns [language.ScriptLanguageUnknown] +// for unrecognized shell types. +func shellToLanguage(shell ShellType) language.ScriptLanguage { + switch shell { + case ShellTypeBash: + return language.ScriptLanguageBash + case ShellTypePowershell: + return language.ScriptLanguagePowerShell + default: + return language.ScriptLanguageUnknown + } +} + +// inferScriptTypeFromFilePath returns the [ShellType] for a file +// based on its extension. Only .sh and .ps1 are valid shell types; +// other extensions return [ErrUnsupportedScriptType]. func inferScriptTypeFromFilePath(path string) (ShellType, error) { fileExtension := filepath.Ext(path) switch fileExtension { diff --git a/cli/azd/pkg/ext/models_test.go b/cli/azd/pkg/ext/models_test.go new file mode 100644 index 00000000000..2fd591fc048 --- /dev/null +++ b/cli/azd/pkg/ext/models_test.go @@ -0,0 +1,409 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ext + +import ( + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/tools/language" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestHookConfig_LanguageField(t *testing.T) { + tests := []struct { + name string + yamlInput string + expectedLanguage language.ScriptLanguage + expectedDir string + }{ + { + name: "OmittedLanguageDefaultsToUnknown", + yamlInput: "run: scripts/hook.sh\n", + expectedLanguage: language.ScriptLanguageUnknown, + expectedDir: "", + }, + { + name: "OmittedDirDefaultsToEmpty", + yamlInput: "run: scripts/hook.py\nlanguage: python\n", + expectedLanguage: language.ScriptLanguagePython, + expectedDir: "", + }, + { + name: "LanguagePython", + yamlInput: "run: scripts/hook.py\nlanguage: python\ndir: src/myapp\n", + expectedLanguage: language.ScriptLanguagePython, + expectedDir: "src/myapp", + }, + { + name: "LanguageJavaScript", + yamlInput: "run: hooks/prebuild.js\nlanguage: js\ndir: hooks\n", + expectedLanguage: language.ScriptLanguageJavaScript, + expectedDir: "hooks", + }, + { + name: "LanguageTypeScript", + yamlInput: "run: hooks/deploy.ts\nlanguage: ts\n", + expectedLanguage: language.ScriptLanguageTypeScript, + expectedDir: "", + }, + { + name: "LanguageDotNet", + yamlInput: "run: hooks/validate.csx\nlanguage: dotnet\ndir: hooks/dotnet\n", + expectedLanguage: language.ScriptLanguageDotNet, + expectedDir: "hooks/dotnet", + }, + { + name: "LanguageBash", + yamlInput: "run: scripts/setup.sh\nlanguage: sh\n", + expectedLanguage: language.ScriptLanguageBash, + expectedDir: "", + }, + { + name: "LanguagePowerShell", + yamlInput: "run: scripts/setup.ps1\nlanguage: pwsh\n", + expectedLanguage: language.ScriptLanguagePowerShell, + expectedDir: "", + }, + { + name: "AllFieldsTogether", + yamlInput: "run: src/hooks/predeploy.py\nshell: sh\n" + + "language: python\ndir: src/hooks\ncontinueOnError: true\n", + expectedLanguage: language.ScriptLanguagePython, + expectedDir: "src/hooks", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var config HookConfig + err := yaml.Unmarshal([]byte(tt.yamlInput), &config) + require.NoError(t, err) + + require.Equal(t, tt.expectedLanguage, config.Language) + require.Equal(t, tt.expectedDir, config.Dir) + }) + } +} + +func TestHookConfig_LanguageRoundTrip(t *testing.T) { + original := HookConfig{ + Run: "hooks/deploy.py", + Shell: ShellTypeBash, + Language: language.ScriptLanguagePython, + Dir: "hooks", + } + + data, err := yaml.Marshal(&original) + require.NoError(t, err) + + var decoded HookConfig + err = yaml.Unmarshal(data, &decoded) + require.NoError(t, err) + + require.Equal(t, original.Language, decoded.Language) + require.Equal(t, original.Dir, decoded.Dir) + require.Equal(t, original.Run, decoded.Run) + require.Equal(t, original.Shell, decoded.Shell) +} + +func TestScriptLanguage_Constants(t *testing.T) { + tests := []struct { + name string + lang language.ScriptLanguage + expected string + }{ + {"Unknown", language.ScriptLanguageUnknown, ""}, + {"Bash", language.ScriptLanguageBash, "sh"}, + {"PowerShell", language.ScriptLanguagePowerShell, "pwsh"}, + {"JavaScript", language.ScriptLanguageJavaScript, "js"}, + {"TypeScript", language.ScriptLanguageTypeScript, "ts"}, + {"Python", language.ScriptLanguagePython, "python"}, + {"DotNet", language.ScriptLanguageDotNet, "dotnet"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, string(tt.lang)) + }) + } +} + +func TestHookConfig_ValidateLanguageResolution(t *testing.T) { + tests := []struct { + name string + config HookConfig + createFile string // relative path to create under cwd + expectedLanguage language.ScriptLanguage + isLanguageHook bool + expectError string + }{ + { + name: "ExplicitLanguagePythonFromFile", + config: HookConfig{ + Name: "test", + Language: language.ScriptLanguagePython, + Run: "script.py", + }, + createFile: "script.py", + expectedLanguage: language.ScriptLanguagePython, + isLanguageHook: true, + }, + { + name: "ExplicitLanguageOverridesExtension", + config: HookConfig{ + Name: "test", + Language: language.ScriptLanguagePython, + Run: "script.js", + }, + createFile: "script.js", + expectedLanguage: language.ScriptLanguagePython, + isLanguageHook: true, + }, + { + name: "ShellBashMapsToLanguage", + config: HookConfig{ + Name: "test", + Shell: ShellTypeBash, + Run: "echo hello", + }, + expectedLanguage: language.ScriptLanguageBash, + isLanguageHook: false, + }, + { + name: "InferPythonFromExtension", + config: HookConfig{ + Name: "test", + Run: "hooks/seed.py", + }, + createFile: "hooks/seed.py", + expectedLanguage: language.ScriptLanguagePython, + isLanguageHook: true, + }, + { + name: "InferJavaScriptFromExtension", + config: HookConfig{ + Name: "test", + Run: "hooks/setup.js", + }, + createFile: "hooks/setup.js", + expectedLanguage: language.ScriptLanguageJavaScript, + isLanguageHook: true, + }, + { + name: "InferTypeScriptFromExtension", + config: HookConfig{ + Name: "test", + Run: "hooks/test.ts", + }, + createFile: "hooks/test.ts", + expectedLanguage: language.ScriptLanguageTypeScript, + isLanguageHook: true, + }, + { + name: "InferDotNetFromExtension", + config: HookConfig{ + Name: "test", + Run: "hooks/run.cs", + }, + createFile: "hooks/run.cs", + expectedLanguage: language.ScriptLanguageDotNet, + isLanguageHook: true, + }, + { + name: "InferBashFromExtension", + config: HookConfig{ + Name: "test", + Run: "hooks/deploy.sh", + }, + createFile: "hooks/deploy.sh", + expectedLanguage: language.ScriptLanguageBash, + isLanguageHook: false, + }, + { + name: "InferPowerShellFromExtension", + config: HookConfig{ + Name: "test", + Run: "hooks/deploy.ps1", + }, + createFile: "hooks/deploy.ps1", + expectedLanguage: language.ScriptLanguagePowerShell, + isLanguageHook: false, + }, + { + name: "InlineScriptDefaultsToOSShell", + config: HookConfig{ + Name: "test", + Run: "echo hello", + }, + expectedLanguage: shellToLanguage( + getDefaultShellForOS(), + ), + isLanguageHook: false, + }, + { + name: "InlineScriptWithLanguagePythonErrors", + config: HookConfig{ + Name: "test", + Language: language.ScriptLanguagePython, + Run: "print('hello')", + }, + expectError: "inline scripts are not supported " + + "for python hooks", + }, + { + name: "InlineScriptWithShellBashIsOK", + config: HookConfig{ + Name: "test", + Shell: ShellTypeBash, + Run: "echo hello", + }, + expectedLanguage: language.ScriptLanguageBash, + isLanguageHook: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := tt.config + cwd := t.TempDir() + config.cwd = cwd + + if tt.createFile != "" { + filePath := filepath.Join(cwd, tt.createFile) + err := os.MkdirAll(filepath.Dir(filePath), 0o755) + require.NoError(t, err) + err = os.WriteFile(filePath, nil, 0o600) + require.NoError(t, err) + } + + err := config.validate() + + if tt.expectError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectError) + return + } + + require.NoError(t, err) + require.Equal( + t, tt.expectedLanguage, config.Language, + ) + require.Equal( + t, tt.isLanguageHook, config.IsLanguageHook(), + ) + }) + } +} + +func TestHookConfig_ValidateDirInference(t *testing.T) { + tests := []struct { + name string + config HookConfig + createFile string + expectedDir string + }{ + { + name: "InferDirFromPythonRunPath", + config: HookConfig{ + Name: "test", + Run: filepath.Join("hooks", "preprovision", "main.py"), + }, + createFile: filepath.Join("hooks", "preprovision", "main.py"), + expectedDir: filepath.Join("hooks", "preprovision"), + }, + { + name: "InferDirFromNestedPath", + config: HookConfig{ + Name: "test", + Run: filepath.Join("src", "tools", "setup.py"), + }, + createFile: filepath.Join("src", "tools", "setup.py"), + expectedDir: filepath.Join("src", "tools"), + }, + { + name: "InferDirForScriptInRoot", + config: HookConfig{ + Name: "test", + Run: "setup.py", + }, + createFile: "setup.py", + expectedDir: ".", + }, + { + name: "ExplicitDirOverridesInferred", + config: HookConfig{ + Name: "test", + Run: filepath.Join("hooks", "deploy-tool", "src", "main.py"), + Dir: filepath.Join("hooks", "deploy-tool"), + }, + createFile: filepath.Join("hooks", "deploy-tool", "src", "main.py"), + expectedDir: filepath.Join("hooks", "deploy-tool"), + }, + { + name: "ShellHookDirUnchanged", + config: HookConfig{ + Name: "test", + Shell: ShellTypeBash, + Run: filepath.Join("hooks", "setup.sh"), + }, + createFile: filepath.Join("hooks", "setup.sh"), + expectedDir: "", + }, + { + name: "InlineScriptDirUnchanged", + config: HookConfig{ + Name: "test", + Shell: ShellTypeBash, + Run: "echo hello", + }, + expectedDir: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := tt.config + cwd := t.TempDir() + config.cwd = cwd + + if tt.createFile != "" { + filePath := filepath.Join(cwd, tt.createFile) + err := os.MkdirAll(filepath.Dir(filePath), 0o755) + require.NoError(t, err) + err = os.WriteFile(filePath, nil, 0o600) + require.NoError(t, err) + } + + err := config.validate() + require.NoError(t, err) + require.Equal(t, tt.expectedDir, config.Dir) + }) + } +} + +func TestHookConfig_IsLanguageHook(t *testing.T) { + tests := []struct { + name string + lang language.ScriptLanguage + expected bool + }{ + {"Python", language.ScriptLanguagePython, true}, + {"JavaScript", language.ScriptLanguageJavaScript, true}, + {"TypeScript", language.ScriptLanguageTypeScript, true}, + {"DotNet", language.ScriptLanguageDotNet, true}, + {"Bash", language.ScriptLanguageBash, false}, + {"PowerShell", language.ScriptLanguagePowerShell, false}, + {"Unknown", language.ScriptLanguageUnknown, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &HookConfig{Language: tt.lang} + require.Equal(t, tt.expected, config.IsLanguageHook()) + }) + } +} diff --git a/cli/azd/pkg/ext/python_hooks_e2e_test.go b/cli/azd/pkg/ext/python_hooks_e2e_test.go new file mode 100644 index 00000000000..aca1c183ddb --- /dev/null +++ b/cli/azd/pkg/ext/python_hooks_e2e_test.go @@ -0,0 +1,961 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ext + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "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/osutil" + "github.com/azure/azure-dev/cli/azd/pkg/tools/language" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockenv" + "github.com/azure/azure-dev/cli/azd/test/ostest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// newPythonTestFixture creates a temp directory with the script file +// and optional requirements.txt, returning the cwd and script path. +func newPythonTestFixture( + t *testing.T, + scriptRelPath string, + withRequirements bool, +) string { + t.Helper() + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + absScript := filepath.Join(cwd, scriptRelPath) + require.NoError(t, os.MkdirAll( + filepath.Dir(absScript), osutil.PermissionDirectory, + )) + require.NoError(t, os.WriteFile( + absScript, nil, osutil.PermissionExecutableFile, + )) + + if withRequirements { + reqPath := filepath.Join( + filepath.Dir(absScript), "requirements.txt", + ) + require.NoError(t, os.WriteFile( + reqPath, []byte("flask\n"), osutil.PermissionFile, + )) + } + + return cwd +} + +// buildRunner is a compact constructor that wires up a +// [HooksRunner] from the mocked context. +func buildRunner( + t *testing.T, + mockCtx *mocks.MockContext, + cwd string, + hooks map[string][]*HookConfig, + env *environment.Environment, +) *HooksRunner { + t.Helper() + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) + + hooksManager := NewHooksManager( + cwd, mockCtx.CommandRunner, + ) + + return NewHooksRunner( + hooksManager, + mockCtx.CommandRunner, + envManager, + mockCtx.Console, + cwd, + hooks, + env, + mockCtx.Container, + ) +} + +// stubPythonVersionCheck registers a mock for the Python +// --version call that always succeeds. +func stubPythonVersionCheck( + mockCtx *mocks.MockContext, +) { + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "--version") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult( + 0, "Python 3.11.0", "", + ), nil + }) +} + +// --------------------------------------------------------------------------- +// E2E Python hook tests +// --------------------------------------------------------------------------- + +// TestPythonHook_AutoDetectFromExtension verifies that a hook with +// run: script.py (no explicit language:) auto-detects Python and +// routes through the HookExecutor pipeline. +func TestPythonHook_AutoDetectFromExtension(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.py") + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + // Language intentionally omitted. + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + var executedScript string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + executedScript = args.Args[0] + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + assert.Contains( + t, executedScript, "predeploy.py", + "auto-detected Python hook should execute the .py script", + ) + + // Verify the config was resolved as a language hook. + hookCfg := hooksMap["predeploy"][0] + assert.Equal( + t, language.ScriptLanguagePython, hookCfg.Language, + ) + assert.True(t, hookCfg.IsLanguageHook()) +} + +// TestPythonHook_ExplicitLanguage verifies that language: python +// in the config uses the Python executor even when the script has +// no .py extension. +func TestPythonHook_ExplicitLanguage(t *testing.T) { + scriptRel := filepath.Join("hooks", "myscript") + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Language: language.ScriptLanguagePython, + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + executed := false + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "myscript") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + executed = true + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + require.True( + t, executed, + "explicit language: python should use Python executor", + ) +} + +// TestPythonHook_EnvVarsPassthrough verifies that azd +// environment variables are forwarded to the Python executor. +func TestPythonHook_EnvVarsPassthrough(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.py") + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues("test", map[string]string{ + "AZURE_ENV_NAME": "dev", + "AZURE_LOCATION": "eastus2", + "MY_CUSTOM_SETTING": "custom_value", + }) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + var capturedEnv []string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + capturedEnv = args.Env + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + require.NotEmpty(t, capturedEnv) + + envMap := envSliceToMap(capturedEnv) + assert.Equal(t, "dev", envMap["AZURE_ENV_NAME"]) + assert.Equal(t, "eastus2", envMap["AZURE_LOCATION"]) + assert.Equal(t, "custom_value", envMap["MY_CUSTOM_SETTING"]) +} + +// TestPythonHook_WithRequirementsTxt verifies that when a +// requirements.txt exists alongside the script, the executor +// creates a venv and installs deps before running the script. +func TestPythonHook_WithRequirementsTxt(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.py") + cwd := newPythonTestFixture(t, scriptRel, true) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + callLog := []string{} + + // Mock "python -m venv …" — venv creation. + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "-m venv") || + strings.Contains(command, "venv") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + callLog = append(callLog, "create-venv") + // Create the venv directory so the executor sees it. + venvDir := filepath.Join( + args.Cwd, + args.Args[len(args.Args)-1], + ) + require.NoError(t, os.MkdirAll( + venvDir, osutil.PermissionDirectory, + )) + return exec.NewRunResult(0, "", ""), nil + }) + + // Mock "pip install -r requirements.txt". + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "install") && + strings.Contains(command, "requirements") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + callLog = append(callLog, "pip-install") + return exec.NewRunResult(0, "", ""), nil + }) + + // Mock actual script execution. + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + callLog = append(callLog, "execute") + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + + // The overall flow: version-check → venv → pip → execute. + assert.Contains(t, callLog, "create-venv") + assert.Contains(t, callLog, "pip-install") + assert.Contains(t, callLog, "execute") +} + +// TestPythonHook_StdoutCapture verifies that the hook execution +// result contains stdout from the Python script process. +func TestPythonHook_StdoutCapture(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.py") + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + // Simulate the script producing stdout. + return exec.NewRunResult( + 0, "Hello from Python hook!", "", + ), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + // RunHooks doesn't return the result directly, but + // the pipeline completes without error confirming the + // execution path ran and stdout was handled. + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + require.NoError(t, err) +} + +// TestPythonHook_NonZeroExitCode verifies that a Python hook +// returning a non-zero exit code produces an error containing +// the exit code information. +func TestPythonHook_NonZeroExitCode(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.py") + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult(1, "", "error output"), + fmt.Errorf("process exited with code 1") + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "'predeploy' hook failed") + assert.Contains(t, err.Error(), "exit code: '1'") +} + +// TestPythonHook_ContinueOnError verifies that when +// continueOnError: true is set and the Python hook fails, the +// error is swallowed and RunHooks returns nil. +func TestPythonHook_ContinueOnError(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.py") + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + ContinueOnError: true, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult(1, "", "error"), + fmt.Errorf("script error") + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + // ContinueOnError should suppress the error. + require.NoError(t, err) +} + +// TestPythonHook_ProjectLevel verifies a Python hook registered +// at the project level (pre) executes through the +// language executor pipeline. +func TestPythonHook_ProjectLevel(t *testing.T) { + scriptRel := filepath.Join("hooks", "preprovision.py") + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "preprovision": {{ + Name: "preprovision", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + executed := false + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "preprovision.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + executed = true + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "provision", + ) + + require.NoError(t, err) + require.True(t, executed, "project-level Python hook should execute") +} + +// TestPythonHook_ServiceLevel verifies a Python hook registered +// at the service level (postdeploy for a service) executes through +// the language executor pipeline with the correct working dir. +func TestPythonHook_ServiceLevel(t *testing.T) { + // Service hooks use a service-specific cwd, simulated here. + serviceDir := filepath.Join("src", "api") + scriptRel := filepath.Join( + serviceDir, "hooks", "postdeploy.py", + ) + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "postdeploy": {{ + Name: "postdeploy", + Run: filepath.Join( + serviceDir, "hooks", "postdeploy.py", + ), + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + var capturedCwd string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "postdeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + capturedCwd = args.Cwd + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePost, nil, "deploy", + ) + + require.NoError(t, err) + + // The execution cwd should be the script's directory. + expectedCwd := filepath.Join( + cwd, serviceDir, "hooks", + ) + assert.Equal(t, expectedCwd, capturedCwd) +} + +// TestPythonHook_ShellHookUnaffected verifies that a shell (.sh) +// hook runs through the shell script executor even when Python +// hooks are present in the same configuration. +func TestPythonHook_ShellHookUnaffected(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + // Create both shell and Python scripts. + shScript := filepath.Join(cwd, "hooks", "prebuild.sh") + pyScript := filepath.Join(cwd, "hooks", "predeploy.py") + for _, p := range []string{shScript, pyScript} { + require.NoError(t, os.MkdirAll( + filepath.Dir(p), osutil.PermissionDirectory, + )) + require.NoError(t, os.WriteFile( + p, nil, osutil.PermissionExecutableFile, + )) + } + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "prebuild": {{ + Name: "prebuild", + Shell: ShellTypeBash, + Run: filepath.Join( + "hooks", "prebuild.sh", + ), + }}, + "predeploy": {{ + Name: "predeploy", + Run: filepath.Join( + "hooks", "predeploy.py", + ), + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + shellRan := false + pythonRan := false + + // Shell hook mock. + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "prebuild.sh") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + shellRan = true + // Shell hooks pass the script as the first arg. + // The shell executor may use forward slashes, so + // compare with forward slashes for portability. + require.Contains( + t, args.Args[0], "prebuild.sh", + ) + return exec.NewRunResult(0, "", ""), nil + }) + + // Python hook mock. + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + pythonRan = true + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + + // Run the shell hook. + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "build", + ) + require.NoError(t, err) + require.True( + t, shellRan, + "shell hook should execute via shell pipeline", + ) + + // Run the Python hook. + err = runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + require.NoError(t, err) + require.True( + t, pythonRan, + "Python hook should execute via language pipeline", + ) +} + +// --------------------------------------------------------------------------- +// Table-driven comprehensive tests +// --------------------------------------------------------------------------- + +// TestPythonHook_ExecutionPipeline uses table-driven subtests to +// verify multiple facets of the Python hook execution pipeline. +func TestPythonHook_ExecutionPipeline(t *testing.T) { + tests := []struct { + name string + scriptRel string + language language.ScriptLanguage + continueOnError bool + exitCode int + execErr error + wantErr bool + errContains string + }{ + { + name: "SuccessAutoDetect", + scriptRel: filepath.Join("hooks", "hook.py"), + exitCode: 0, + wantErr: false, + }, + { + name: "SuccessExplicitLanguage", + scriptRel: filepath.Join("hooks", "run"), + language: language.ScriptLanguagePython, + exitCode: 0, + wantErr: false, + }, + { + name: "FailWithExitCode2", + scriptRel: filepath.Join("hooks", "fail.py"), + exitCode: 2, + execErr: fmt.Errorf("exit code 2"), + wantErr: true, + errContains: "exit code: '2'", + }, + { + name: "FailSuppressedByContinueOnError", + scriptRel: filepath.Join("hooks", "warn.py"), + continueOnError: true, + exitCode: 1, + execErr: fmt.Errorf("exit code 1"), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cwd := newPythonTestFixture( + t, tt.scriptRel, false, + ) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hookCfg := &HookConfig{ + Name: "predeploy", + Run: tt.scriptRel, + ContinueOnError: tt.continueOnError, + } + if tt.language != language.ScriptLanguageUnknown { + hookCfg.Language = tt.language + } + + hooksMap := map[string][]*HookConfig{ + "predeploy": {hookCfg}, + } + + mockCtx := mocks.NewMockContext( + context.Background(), + ) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + // Derive the script base name for matching. + scriptBase := filepath.Base(tt.scriptRel) + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains( + command, scriptBase, + ) + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult( + tt.exitCode, "", "", + ), tt.execErr + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, + HookTypePre, nil, "deploy", + ) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains( + t, err.Error(), tt.errContains, + ) + } + } else { + require.NoError(t, err) + } + }) + } +} + +// TestPythonHook_PythonBinaryResolution verifies that the correct +// Python binary is invoked based on the platform. +func TestPythonHook_PythonBinaryResolution(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.py") + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + var capturedCmd string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + capturedCmd = args.Cmd + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + require.NotEmpty(t, capturedCmd) + + // Without a venv, the executor uses system Python. + if runtime.GOOS == "windows" { + // The mock ToolInPath returns nil → "py" is preferred. + assert.True( + t, + capturedCmd == "py" || capturedCmd == "python", + "expected py or python on Windows, got %s", + capturedCmd, + ) + } else { + assert.Equal(t, "python3", capturedCmd) + } +} + +// TestPythonHook_ExplicitDirOverridesCwd verifies that +// the Dir field in HookConfig overrides the default working +// directory for language hook execution. +func TestPythonHook_ExplicitDirOverridesCwd(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + // Create script and a custom directory. + scriptDir := filepath.Join(cwd, "hooks") + require.NoError(t, os.MkdirAll( + scriptDir, osutil.PermissionDirectory, + )) + require.NoError(t, os.WriteFile( + filepath.Join(scriptDir, "predeploy.py"), + nil, osutil.PermissionExecutableFile, + )) + + customDir := filepath.Join(cwd, "custom_workdir") + require.NoError(t, os.MkdirAll( + customDir, osutil.PermissionDirectory, + )) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: filepath.Join("hooks", "predeploy.py"), + Dir: customDir, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + var capturedCwd string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + capturedCwd = args.Cwd + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + assert.Equal( + t, customDir, capturedCwd, + "Dir should override the default working directory", + ) +} + +// TestPythonHook_InlineScriptRejected verifies that inline +// Python scripts (no file path) are rejected with a clear error +// since language hooks require file-based scripts. +func TestPythonHook_InlineScriptRejected(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Language: language.ScriptLanguagePython, + Run: "print('hello')", + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.Error(t, err) + assert.Contains( + t, err.Error(), "inline scripts are not supported", + ) +} diff --git a/cli/azd/pkg/tools/bash/bash.go b/cli/azd/pkg/tools/bash/bash.go index dcec5c98076..0bb7f582f30 100644 --- a/cli/azd/pkg/tools/bash/bash.go +++ b/cli/azd/pkg/tools/bash/bash.go @@ -12,24 +12,25 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/tools" ) -// Creates a new BashScript command runner -func NewBashScript(commandRunner exec.CommandRunner, cwd string, envVars []string) tools.Script { - return &bashScript{ - commandRunner: commandRunner, - cwd: cwd, - envVars: envVars, - } +// NewExecutor creates a bash HookExecutor. Takes only IoC-injectable deps. +func NewExecutor(commandRunner exec.CommandRunner) tools.HookExecutor { + return &bashExecutor{commandRunner: commandRunner} } -type bashScript struct { +type bashExecutor struct { commandRunner exec.CommandRunner - cwd string - envVars []string } -// Executes the specified bash script -// When interactive is true will attach to stdin, stdout & stderr -func (bs *bashScript) Execute(ctx context.Context, path string, options tools.ExecOptions) (exec.RunResult, error) { +// Prepare is a no-op for bash — bash is assumed available on all platforms. +func (b *bashExecutor) Prepare(_ context.Context, _ string, _ tools.ExecutionContext) error { + return nil +} + +// Execute runs the specified bash script. +// When interactive is true will attach to stdin, stdout & stderr. +func (b *bashExecutor) Execute( + ctx context.Context, path string, execCtx tools.ExecutionContext, +) (exec.RunResult, error) { var runArgs exec.RunArgs // Bash likes all path separators in POSIX format path = strings.ReplaceAll(path, "\\", "/") @@ -41,17 +42,17 @@ func (bs *bashScript) Execute(ctx context.Context, path string, options tools.Ex } runArgs = runArgs. - WithCwd(bs.cwd). - WithEnv(bs.envVars). + WithCwd(execCtx.Cwd). + WithEnv(execCtx.EnvVars). WithShell(true) - if options.Interactive != nil { - runArgs = runArgs.WithInteractive(*options.Interactive) + if execCtx.Interactive != nil { + runArgs = runArgs.WithInteractive(*execCtx.Interactive) } - if options.StdOut != nil { - runArgs = runArgs.WithStdOut(options.StdOut) + if execCtx.StdOut != nil { + runArgs = runArgs.WithStdOut(execCtx.StdOut) } - return bs.commandRunner.Run(ctx, runArgs) + return b.commandRunner.Run(ctx, runArgs) } diff --git a/cli/azd/pkg/tools/bash/bash_test.go b/cli/azd/pkg/tools/bash/bash_test.go index e84346f4e3f..ffa9b25acd9 100644 --- a/cli/azd/pkg/tools/bash/bash_test.go +++ b/cli/azd/pkg/tools/bash/bash_test.go @@ -23,6 +23,14 @@ func Test_Bash_Execute(t *testing.T) { "b=banana", } + t.Run("Prepare", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + executor := NewExecutor(mockContext.CommandRunner) + execCtx := tools.ExecutionContext{Cwd: workingDir, EnvVars: env} + err := executor.Prepare(*mockContext.Context, scriptPath, execCtx) + require.NoError(t, err) + }) + t.Run("Success", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) @@ -42,11 +50,16 @@ func Test_Bash_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - bashScript := NewBashScript(mockContext.CommandRunner, workingDir, env) - runResult, err := bashScript.Execute( + executor := NewExecutor(mockContext.CommandRunner) + execCtx := tools.ExecutionContext{ + Cwd: workingDir, + EnvVars: env, + Interactive: new(true), + } + runResult, err := executor.Execute( *mockContext.Context, scriptPath, - tools.ExecOptions{Interactive: new(true)}, + execCtx, ) require.NotNil(t, runResult) @@ -62,11 +75,16 @@ func Test_Bash_Execute(t *testing.T) { return exec.NewRunResult(1, "", "error message"), errors.New("error message") }) - bashScript := NewBashScript(mockContext.CommandRunner, workingDir, env) - runResult, err := bashScript.Execute( + executor := NewExecutor(mockContext.CommandRunner) + execCtx := tools.ExecutionContext{ + Cwd: workingDir, + EnvVars: env, + Interactive: new(true), + } + runResult, err := executor.Execute( *mockContext.Context, scriptPath, - tools.ExecOptions{Interactive: new(true)}, + execCtx, ) require.Equal(t, 1, runResult.ExitCode) @@ -75,10 +93,14 @@ func Test_Bash_Execute(t *testing.T) { tests := []struct { name string - value tools.ExecOptions + value tools.ExecutionContext }{ - {name: "Interactive", value: tools.ExecOptions{Interactive: new(true)}}, - {name: "NonInteractive", value: tools.ExecOptions{Interactive: new(false)}}, + {name: "Interactive", value: tools.ExecutionContext{ + Cwd: workingDir, EnvVars: env, Interactive: new(true), + }}, + {name: "NonInteractive", value: tools.ExecutionContext{ + Cwd: workingDir, EnvVars: env, Interactive: new(false), + }}, } for _, test := range tests { @@ -92,8 +114,8 @@ func Test_Bash_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - bashScript := NewBashScript(mockContext.CommandRunner, workingDir, env) - runResult, err := bashScript.Execute(*mockContext.Context, scriptPath, test.value) + executor := NewExecutor(mockContext.CommandRunner) + runResult, err := executor.Execute(*mockContext.Context, scriptPath, test.value) require.NotNil(t, runResult) require.NoError(t, err) diff --git a/cli/azd/pkg/tools/language/executor.go b/cli/azd/pkg/tools/language/executor.go new file mode 100644 index 00000000000..46faa2070e4 --- /dev/null +++ b/cli/azd/pkg/tools/language/executor.go @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "path/filepath" + "strings" +) + +// ScriptLanguage identifies the programming language of a hook script. +// The string value matches the token users write in the "language" field +// of azure.yaml hook configurations. +type ScriptLanguage string + +const ( + // ScriptLanguageUnknown indicates the language could not be + // determined from the file extension or explicit configuration. + ScriptLanguageUnknown ScriptLanguage = "" + // ScriptLanguageBash identifies Bash shell scripts (.sh files). + ScriptLanguageBash ScriptLanguage = "sh" + // ScriptLanguagePowerShell identifies PowerShell scripts (.ps1 files). + ScriptLanguagePowerShell ScriptLanguage = "pwsh" + // ScriptLanguageJavaScript identifies JavaScript scripts (.js files). + // Not yet supported — IoC resolution will fail with a descriptive error. + ScriptLanguageJavaScript ScriptLanguage = "js" + // ScriptLanguageTypeScript identifies TypeScript scripts (.ts files). + // Not yet supported — IoC resolution will fail with a descriptive error. + ScriptLanguageTypeScript ScriptLanguage = "ts" + // ScriptLanguagePython identifies Python scripts (.py files). + ScriptLanguagePython ScriptLanguage = "python" + // ScriptLanguageDotNet identifies .NET (C#) scripts (.cs files). + // Not yet supported — IoC resolution will fail with a descriptive error. + ScriptLanguageDotNet ScriptLanguage = "dotnet" +) + +// InferLanguageFromPath determines the [ScriptLanguage] from the +// file extension of the given path. Extension matching is +// case-insensitive. The following extensions are recognized: +// +// - .py → [ScriptLanguagePython] +// - .js → [ScriptLanguageJavaScript] +// - .ts → [ScriptLanguageTypeScript] +// - .cs → [ScriptLanguageDotNet] +// - .sh → [ScriptLanguageBash] +// - .ps1 → [ScriptLanguagePowerShell] +// +// Returns [ScriptLanguageUnknown] for unrecognized extensions. +func InferLanguageFromPath(path string) ScriptLanguage { + ext := strings.ToLower(filepath.Ext(path)) + + switch ext { + case ".py": + return ScriptLanguagePython + case ".js": + return ScriptLanguageJavaScript + case ".ts": + return ScriptLanguageTypeScript + case ".cs": + return ScriptLanguageDotNet + case ".sh": + return ScriptLanguageBash + case ".ps1": + return ScriptLanguagePowerShell + default: + return ScriptLanguageUnknown + } +} diff --git a/cli/azd/pkg/tools/language/executor_test.go b/cli/azd/pkg/tools/language/executor_test.go new file mode 100644 index 00000000000..21a09b49f18 --- /dev/null +++ b/cli/azd/pkg/tools/language/executor_test.go @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInferLanguageFromPath(t *testing.T) { + tests := []struct { + name string + path string + expected ScriptLanguage + }{ + { + name: "Python", + path: "hooks/pre-deploy.py", + expected: ScriptLanguagePython, + }, + { + name: "JavaScript", + path: "hooks/pre-deploy.js", + expected: ScriptLanguageJavaScript, + }, + { + name: "TypeScript", + path: "hooks/pre-deploy.ts", + expected: ScriptLanguageTypeScript, + }, + { + name: "DotNet", + path: "hooks/pre-deploy.cs", + expected: ScriptLanguageDotNet, + }, + { + name: "Bash", + path: "hooks/pre-deploy.sh", + expected: ScriptLanguageBash, + }, + { + name: "PowerShell", + path: "hooks/pre-deploy.ps1", + expected: ScriptLanguagePowerShell, + }, + { + name: "UnknownTxt", + path: "hooks/readme.txt", + expected: ScriptLanguageUnknown, + }, + { + name: "UnknownGo", + path: "hooks/main.go", + expected: ScriptLanguageUnknown, + }, + { + name: "NoExtension", + path: "hooks/Makefile", + expected: ScriptLanguageUnknown, + }, + { + name: "EmptyPath", + path: "", + expected: ScriptLanguageUnknown, + }, + { + name: "CaseInsensitivePY", + path: "hooks/deploy.PY", + expected: ScriptLanguagePython, + }, + { + name: "CaseInsensitiveJs", + path: "hooks/deploy.Js", + expected: ScriptLanguageJavaScript, + }, + { + name: "CaseInsensitivePS1", + path: "hooks/deploy.PS1", + expected: ScriptLanguagePowerShell, + }, + { + name: "MultipleDots", + path: "hooks/pre.deploy.py", + expected: ScriptLanguagePython, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := InferLanguageFromPath(tt.path) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/cli/azd/pkg/tools/language/project_discovery.go b/cli/azd/pkg/tools/language/project_discovery.go new file mode 100644 index 00000000000..26ca1f4d748 --- /dev/null +++ b/cli/azd/pkg/tools/language/project_discovery.go @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package language provides project file discovery and dependency +// management for multi-language hook scripts. +package language + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +// ProjectContext holds metadata about a discovered project file, +// used to determine how to install dependencies for a hook script. +type ProjectContext struct { + // ProjectDir is the directory containing the project file. + ProjectDir string + // DependencyFile is the absolute path to the dependency file + // (e.g. requirements.txt, package.json, *.csproj). + DependencyFile string + // Language is the language inferred from the project file. + Language ScriptLanguage +} + +// projectFileEntry maps a filename or glob pattern to a language. +type projectFileEntry struct { + Name string // exact filename or glob pattern + Language ScriptLanguage // inferred language + IsGlob bool // true for patterns like "*.*proj" +} + +// knownProjectFiles defines project files to search for, in priority order. +// The first match found in a directory wins. +// +// Python: pyproject.toml is preferred over requirements.txt, matching the +// convention in framework_service_python.go and internal/appdetect/python.go +// (PEP 621 preference). +// +// NOTE: This is intentionally separate from internal/appdetect/ which walks +// DOWN a tree to detect service projects. Hook discovery walks UP from a +// script to find the nearest project context. +var knownProjectFiles = []projectFileEntry{ + {Name: "pyproject.toml", Language: ScriptLanguagePython}, + {Name: "requirements.txt", Language: ScriptLanguagePython}, + {Name: "package.json", Language: ScriptLanguageJavaScript}, + {Name: "*.*proj", Language: ScriptLanguageDotNet, IsGlob: true}, +} + +// DiscoverProjectFile walks up the directory tree from the directory +// containing scriptPath, looking for known project files to infer the +// project context for dependency installation. +// +// The search stops at boundaryDir to prevent path traversal outside +// the project or service root. Returns nil without error when no +// project file is found — hooks can still run without project context. +func DiscoverProjectFile( + scriptPath string, boundaryDir string, +) (*ProjectContext, error) { + scriptDir := filepath.Dir(scriptPath) + + absScript, err := filepath.Abs(scriptDir) + if err != nil { + return nil, fmt.Errorf( + "resolving script directory %q: %w", scriptDir, err, + ) + } + + absBoundary, err := filepath.Abs(boundaryDir) + if err != nil { + return nil, fmt.Errorf( + "resolving boundary directory %q: %w", + boundaryDir, err, + ) + } + + current := absScript + for { + result, err := discoverInDirectory(current) + if err != nil { + return nil, err + } + if result != nil { + return result, nil + } + + // Stop when we've reached the boundary directory. + if pathsEqual(current, absBoundary) { + return nil, nil + } + + parent := filepath.Dir(current) + // Stop at filesystem root (parent == current). + if parent == current { + return nil, nil + } + current = parent + } +} + +// discoverInDirectory scans a single directory for known project +// files. Returns the first match or nil if none is found. +func discoverInDirectory(dir string) (*ProjectContext, error) { + for _, entry := range knownProjectFiles { + if entry.IsGlob { + pattern := filepath.Join(dir, entry.Name) + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf( + "glob %q in %q: %w", + entry.Name, dir, err, + ) + } + if len(matches) > 0 { + return &ProjectContext{ + ProjectDir: dir, + DependencyFile: matches[0], + Language: entry.Language, + }, nil + } + } else { + candidate := filepath.Join(dir, entry.Name) + info, err := os.Stat(candidate) + if err == nil && !info.IsDir() { + return &ProjectContext{ + ProjectDir: dir, + DependencyFile: candidate, + Language: entry.Language, + }, nil + } + } + } + return nil, nil +} + +// pathsEqual compares two cleaned absolute paths for equality. +// On Windows the comparison is case-insensitive to match the +// filesystem behavior. +func pathsEqual(a, b string) bool { + cleanA := filepath.Clean(a) + cleanB := filepath.Clean(b) + if runtime.GOOS == "windows" { + return strings.EqualFold(cleanA, cleanB) + } + return cleanA == cleanB +} diff --git a/cli/azd/pkg/tools/language/project_discovery_test.go b/cli/azd/pkg/tools/language/project_discovery_test.go new file mode 100644 index 00000000000..eddbf8a2a81 --- /dev/null +++ b/cli/azd/pkg/tools/language/project_discovery_test.go @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscoverProjectFile_Python(t *testing.T) { + dir := t.TempDir() + scriptPath := filepath.Join(dir, "hook.py") + projectFile := filepath.Join(dir, "requirements.txt") + + writeFile(t, projectFile, "flask\n") + + result, err := DiscoverProjectFile(scriptPath, dir) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, dir, result.ProjectDir) + assert.Equal(t, projectFile, result.DependencyFile) + assert.Equal(t, ScriptLanguagePython, result.Language) +} + +func TestDiscoverProjectFile_PythonPyproject(t *testing.T) { + dir := t.TempDir() + scriptPath := filepath.Join(dir, "hook.py") + projectFile := filepath.Join(dir, "pyproject.toml") + + writeFile(t, projectFile, "[project]\n") + + result, err := DiscoverProjectFile(scriptPath, dir) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, dir, result.ProjectDir) + assert.Equal(t, projectFile, result.DependencyFile) + assert.Equal(t, ScriptLanguagePython, result.Language) +} + +func TestDiscoverProjectFile_JavaScript(t *testing.T) { + dir := t.TempDir() + scriptPath := filepath.Join(dir, "hook.js") + projectFile := filepath.Join(dir, "package.json") + + writeFile(t, projectFile, "{}\n") + + result, err := DiscoverProjectFile(scriptPath, dir) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, dir, result.ProjectDir) + assert.Equal(t, projectFile, result.DependencyFile) + assert.Equal(t, ScriptLanguageJavaScript, result.Language) +} + +func TestDiscoverProjectFile_DotNet(t *testing.T) { + dir := t.TempDir() + scriptPath := filepath.Join(dir, "hook.cs") + projectFile := filepath.Join(dir, "test.csproj") + + writeFile(t, projectFile, "\n") + + result, err := DiscoverProjectFile(scriptPath, dir) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, dir, result.ProjectDir) + assert.Equal(t, projectFile, result.DependencyFile) + assert.Equal(t, ScriptLanguageDotNet, result.Language) +} + +func TestDiscoverProjectFile_DotNetFsProj(t *testing.T) { + dir := t.TempDir() + scriptPath := filepath.Join(dir, "hook.fsx") + projectFile := filepath.Join(dir, "test.fsproj") + + writeFile(t, projectFile, "\n") + + result, err := DiscoverProjectFile(scriptPath, dir) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, dir, result.ProjectDir) + assert.Equal(t, projectFile, result.DependencyFile) + assert.Equal(t, ScriptLanguageDotNet, result.Language) +} + +func TestDiscoverProjectFile_WalkUp(t *testing.T) { + // Project file in parent, script in child subdirectory. + // + // dir/ + // requirements.txt + // hooks/ + // hook.py <- script starts here + dir := t.TempDir() + hooksDir := filepath.Join(dir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0o700)) + + projectFile := filepath.Join(dir, "requirements.txt") + writeFile(t, projectFile, "flask\n") + + scriptPath := filepath.Join(hooksDir, "hook.py") + + result, err := DiscoverProjectFile(scriptPath, dir) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, dir, result.ProjectDir) + assert.Equal(t, projectFile, result.DependencyFile) + assert.Equal(t, ScriptLanguagePython, result.Language) +} + +func TestDiscoverProjectFile_BoundaryRespected(t *testing.T) { + // Project file exists above the boundary, so it must NOT be found. + // + // root/ + // requirements.txt <- above boundary + // child/ <- boundary + // hook.py <- script starts here + root := t.TempDir() + child := filepath.Join(root, "child") + require.NoError(t, os.MkdirAll(child, 0o700)) + + // Place project file above the boundary. + writeFile(t, filepath.Join(root, "requirements.txt"), "flask\n") + + scriptPath := filepath.Join(child, "hook.py") + + result, err := DiscoverProjectFile(scriptPath, child) + + require.NoError(t, err) + assert.Nil(t, result, "project file above boundary must not be found") +} + +func TestDiscoverProjectFile_NoProjectFile(t *testing.T) { + // Empty directory — no project files anywhere. + dir := t.TempDir() + scriptPath := filepath.Join(dir, "hook.py") + + result, err := DiscoverProjectFile(scriptPath, dir) + + require.NoError(t, err) + assert.Nil(t, result, "expected nil when no project file exists") +} + +func TestDiscoverProjectFile_Priority(t *testing.T) { + // Multiple project files in the same directory — the one with the + // highest priority (earliest in knownProjectFiles) should win. + dir := t.TempDir() + scriptPath := filepath.Join(dir, "hook.py") + + // Create both Python and JavaScript project files. + reqFile := filepath.Join(dir, "requirements.txt") + writeFile(t, reqFile, "flask\n") + writeFile(t, filepath.Join(dir, "package.json"), "{}\n") + + result, err := DiscoverProjectFile(scriptPath, dir) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, ScriptLanguagePython, result.Language, + "requirements.txt has higher priority than package.json") + assert.Equal(t, reqFile, result.DependencyFile) +} + +func TestDiscoverProjectFile_PyprojectOverRequirements(t *testing.T) { + // When both pyproject.toml and requirements.txt exist in the + // same directory, pyproject.toml wins — matching the convention + // in framework_service_python.go and internal/appdetect/python.go. + dir := t.TempDir() + scriptPath := filepath.Join(dir, "hook.py") + + pyprojectFile := filepath.Join(dir, "pyproject.toml") + writeFile(t, pyprojectFile, "[project]\nname = \"demo\"\n") + writeFile(t, filepath.Join(dir, "requirements.txt"), "flask\n") + + result, err := DiscoverProjectFile(scriptPath, dir) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, ScriptLanguagePython, result.Language) + assert.Equal(t, pyprojectFile, result.DependencyFile, + "pyproject.toml should win over requirements.txt (PEP 621)") +} + +func TestDiscoverProjectFile_WalkUpMultipleLevels(t *testing.T) { + // Script is nested several levels deep; project file is at root. + // + // root/ + // package.json + // a/ + // b/ + // hook.js <- script starts here + root := t.TempDir() + deep := filepath.Join(root, "a", "b") + require.NoError(t, os.MkdirAll(deep, 0o700)) + + projectFile := filepath.Join(root, "package.json") + writeFile(t, projectFile, "{}\n") + + scriptPath := filepath.Join(deep, "hook.js") + + result, err := DiscoverProjectFile(scriptPath, root) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, root, result.ProjectDir) + assert.Equal(t, projectFile, result.DependencyFile) + assert.Equal(t, ScriptLanguageJavaScript, result.Language) +} + +func TestDiscoverProjectFile_ClosestWins(t *testing.T) { + // Project files at multiple levels — the closest to the script wins. + // + // root/ + // requirements.txt <- farther + // child/ + // package.json <- closer to script + // hook.js + root := t.TempDir() + child := filepath.Join(root, "child") + require.NoError(t, os.MkdirAll(child, 0o700)) + + writeFile(t, filepath.Join(root, "requirements.txt"), "flask\n") + closerFile := filepath.Join(child, "package.json") + writeFile(t, closerFile, "{}\n") + + scriptPath := filepath.Join(child, "hook.js") + + result, err := DiscoverProjectFile(scriptPath, root) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, child, result.ProjectDir) + assert.Equal(t, closerFile, result.DependencyFile) + assert.Equal(t, ScriptLanguageJavaScript, result.Language, + "closer package.json should win over farther requirements.txt") +} + +// writeFile is a test helper that creates a file with the given content. +func writeFile(t *testing.T, path, content string) { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +} diff --git a/cli/azd/pkg/tools/language/python_executor.go b/cli/azd/pkg/tools/language/python_executor.go new file mode 100644 index 00000000000..412cfc361a7 --- /dev/null +++ b/cli/azd/pkg/tools/language/python_executor.go @@ -0,0 +1,283 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "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/pkg/tools/python" +) + +// pythonTools abstracts the Python CLI operations needed by +// pythonExecutor, decoupling it from the concrete [python.Cli] +// for testability. [python.Cli] satisfies this interface. +type pythonTools interface { + CheckInstalled(ctx context.Context) error + CreateVirtualEnv( + ctx context.Context, + workingDir, name string, + env []string, + ) error + InstallRequirements( + ctx context.Context, + workingDir, environment, requirementFile string, + env []string, + ) error + InstallProject( + ctx context.Context, + workingDir, environment string, + env []string, + ) error +} + +// pythonExecutor implements [tools.HookExecutor] for Python scripts. +// It manages virtual environment creation and dependency +// installation when a project file (requirements.txt or +// pyproject.toml) is discovered near the script. +type pythonExecutor struct { + commandRunner exec.CommandRunner + pythonCli pythonTools + + // venvPath is set by Prepare when a project context with a + // dependency file is discovered. Empty means system Python. + venvPath string +} + +// NewPythonExecutor creates a Python HookExecutor. Takes only IoC-injectable deps. +func NewPythonExecutor( + commandRunner exec.CommandRunner, + pythonCli *python.Cli, +) tools.HookExecutor { + return newPythonExecutorInternal(commandRunner, pythonCli) +} + +// newPythonExecutorInternal creates a pythonExecutor using the +// pythonTools interface. This allows tests to inject mocks. +func newPythonExecutorInternal( + commandRunner exec.CommandRunner, + pythonCli pythonTools, +) *pythonExecutor { + return &pythonExecutor{ + commandRunner: commandRunner, + pythonCli: pythonCli, + } +} + +// Prepare verifies that Python is installed and, when a project +// file is found, creates a virtual environment and installs +// dependencies. The venv naming convention follows +// [framework_service_python.go]: {projectDirName}_env. +func (e *pythonExecutor) Prepare( + ctx context.Context, + scriptPath string, + execCtx tools.ExecutionContext, +) error { + // 1. Verify Python is installed. + if err := e.pythonCli.CheckInstalled(ctx); err != nil { + return fmt.Errorf( + "python 3 is required to run this hook but was not found on PATH. "+ + "Install Python from https://www.python.org/downloads/ : %w", + err, + ) + } + + // 2. Discover project context for dependency installation. + projCtx, err := DiscoverProjectFile( + scriptPath, execCtx.BoundaryDir, + ) + if err != nil { + return fmt.Errorf( + "discovering project file: %w", err, + ) + } + + // No project file — run with system Python directly. + if projCtx == nil { + return nil + } + + // 3. Set up virtual environment. + venvName := venvNameForDir(projCtx.ProjectDir) + venvPath := filepath.Join(projCtx.ProjectDir, venvName) + + if err := e.ensureVenv( + ctx, projCtx.ProjectDir, venvName, venvPath, execCtx.EnvVars, + ); err != nil { + return err + } + + // 4. Install dependencies from the discovered file. + depFile := filepath.Base(projCtx.DependencyFile) + if err := e.installDeps( + ctx, projCtx.ProjectDir, venvName, depFile, execCtx.EnvVars, + ); err != nil { + return err + } + + e.venvPath = venvPath + return nil +} + +// ensureVenv creates the virtual environment if it does not +// already exist. If the venv directory exists, creation is +// skipped. Non-existence errors (e.g. permission denied) are +// propagated immediately. +func (e *pythonExecutor) ensureVenv( + ctx context.Context, + projectDir, venvName, venvPath string, + envVars []string, +) error { + _, statErr := os.Stat(venvPath) + if statErr == nil { + // Venv directory already exists — skip creation. + return nil + } + + if !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf( + "virtual environment at %q is not accessible "+ + "(check file permissions): %w", + venvPath, statErr, + ) + } + + if err := e.pythonCli.CreateVirtualEnv( + ctx, projectDir, venvName, envVars, + ); err != nil { + return fmt.Errorf( + "creating python virtual environment at %q failed. "+ + "Ensure Python 3.3+ is installed with the venv module: %w", + filepath.Join(projectDir, venvName), err, + ) + } + return nil +} + +// installDeps installs Python dependencies from the given file +// into the virtual environment identified by venvName. +func (e *pythonExecutor) installDeps( + ctx context.Context, + projectDir, venvName, depFile string, + envVars []string, +) error { + switch depFile { + case "requirements.txt": + if err := e.pythonCli.InstallRequirements( + ctx, projectDir, venvName, depFile, envVars, + ); err != nil { + return fmt.Errorf( + "installing python requirements from %s. "+ + "Check that the file is valid and all packages are available: %w", + depFile, err, + ) + } + case "pyproject.toml": + if err := e.pythonCli.InstallProject( + ctx, projectDir, venvName, envVars, + ); err != nil { + return fmt.Errorf( + "installing python project from pyproject.toml. "+ + "Check the [build-system] section and ensure pip >= 21.3: %w", + err, + ) + } + } + return nil +} + +// Execute runs the Python script at the given path. When Prepare +// has configured a virtual environment, the venv's Python binary +// is used; otherwise the system Python is resolved using the +// same platform heuristics as [python.Cli]. +func (e *pythonExecutor) Execute( + ctx context.Context, + scriptPath string, + execCtx tools.ExecutionContext, +) (exec.RunResult, error) { + pyCmd := e.resolvePythonPath() + + runArgs := exec. + NewRunArgs(pyCmd, scriptPath). + WithEnv(execCtx.EnvVars) + + // Prefer configured cwd; fall back to script's directory. + cwd := execCtx.Cwd + if cwd == "" { + cwd = filepath.Dir(scriptPath) + } + runArgs = runArgs.WithCwd(cwd) + + if execCtx.Interactive != nil { + runArgs = runArgs.WithInteractive( + *execCtx.Interactive, + ) + } + if execCtx.StdOut != nil { + runArgs = runArgs.WithStdOut(execCtx.StdOut) + } + + return e.commandRunner.Run(ctx, runArgs) +} + +// resolvePythonPath returns the path to the Python executable. +// When a virtual environment was configured by [Prepare], it +// returns the venv's Python binary; otherwise it falls back to +// the system-level Python command. +func (e *pythonExecutor) resolvePythonPath() string { + if e.venvPath != "" { + if runtime.GOOS == "windows" { + return filepath.Join( + e.venvPath, "Scripts", "python.exe", + ) + } + return filepath.Join(e.venvPath, "bin", "python") + } + return resolvePythonCmd(e.commandRunner) +} + +// resolvePythonCmd returns the platform-appropriate Python +// command name, following the same resolution strategy as +// [python.Cli]: on Windows it prefers "py" (PEP 397 launcher), +// falling back to "python"; on other platforms it uses "python3". +func resolvePythonCmd( + commandRunner exec.CommandRunner, +) string { + if runtime.GOOS == "windows" { + // Try py launcher first (PEP 397), then python. + for _, cmd := range []string{"py", "python"} { + if commandRunner.ToolInPath(cmd) == nil { + return cmd + } + } + // Fallback even if not found — Prepare() will catch this. + return "python" + } + // Unix: python3 is the standard command. + if commandRunner.ToolInPath("python3") == nil { + return "python3" + } + // Fallback — Prepare() will catch missing Python. + return "python3" +} + +// venvNameForDir computes a virtual environment directory name +// from the given project directory path. It follows the naming +// convention in [framework_service_python.go]: {baseName}_env. +func venvNameForDir(projectDir string) string { + trimmed := strings.TrimSpace(projectDir) + if len(trimmed) > 0 && + trimmed[len(trimmed)-1] == os.PathSeparator { + trimmed = trimmed[:len(trimmed)-1] + } + _, base := filepath.Split(trimmed) + return base + "_env" +} diff --git a/cli/azd/pkg/tools/language/python_executor_test.go b/cli/azd/pkg/tools/language/python_executor_test.go new file mode 100644 index 00000000000..1cda4fd0ced --- /dev/null +++ b/cli/azd/pkg/tools/language/python_executor_test.go @@ -0,0 +1,413 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/tools" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// mockPythonTools — test double for the pythonTools interface +// --------------------------------------------------------------------------- + +type mockPythonTools struct { + checkInstalledErr error + createVenvErr error + installReqErr error + installProjErr error + + createVenvCalled bool + installReqCalled bool + installProjCalled bool + + venvDir string // CreateVirtualEnv workingDir + venvName string // CreateVirtualEnv name + reqDir string // InstallRequirements workingDir + reqVenv string // InstallRequirements environment + reqFile string // InstallRequirements requirementFile + projDir string // InstallProject workingDir + projVenv string // InstallProject environment +} + +func (m *mockPythonTools) CheckInstalled( + _ context.Context, +) error { + return m.checkInstalledErr +} + +func (m *mockPythonTools) CreateVirtualEnv( + _ context.Context, + workingDir, name string, + _ []string, +) error { + m.createVenvCalled = true + m.venvDir = workingDir + m.venvName = name + return m.createVenvErr +} + +func (m *mockPythonTools) InstallRequirements( + _ context.Context, + workingDir, environment, requirementFile string, + _ []string, +) error { + m.installReqCalled = true + m.reqDir = workingDir + m.reqVenv = environment + m.reqFile = requirementFile + return m.installReqErr +} + +func (m *mockPythonTools) InstallProject( + _ context.Context, + workingDir, environment string, + _ []string, +) error { + m.installProjCalled = true + m.projDir = workingDir + m.projVenv = environment + return m.installProjErr +} + +// mockCommandRunner is a minimal mock of [exec.CommandRunner] +// used to construct test dependencies without invoking real +// processes. +type mockCommandRunner struct { + lastRunArgs exec.RunArgs + runResult exec.RunResult + runErr error + toolInPathFn func(name string) error +} + +func (m *mockCommandRunner) Run( + _ context.Context, + args exec.RunArgs, +) (exec.RunResult, error) { + m.lastRunArgs = args + return m.runResult, m.runErr +} + +func (m *mockCommandRunner) RunList( + _ context.Context, + _ []string, + _ exec.RunArgs, +) (exec.RunResult, error) { + return m.runResult, m.runErr +} + +func (m *mockCommandRunner) ToolInPath(name string) error { + if m.toolInPathFn != nil { + return m.toolInPathFn(name) + } + return nil +} + +// --------------------------------------------------------------------------- +// Prepare tests +// --------------------------------------------------------------------------- + +func TestPythonPrepare_PythonNotInstalled(t *testing.T) { + cli := &mockPythonTools{ + checkInstalledErr: errors.New("python not found"), + } + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{BoundaryDir: t.TempDir()} + err := e.Prepare(t.Context(), "/any/hook.py", execCtx) + + require.Error(t, err) + assert.Contains(t, err.Error(), "python 3 is required") + assert.ErrorIs(t, err, cli.checkInstalledErr) + assert.False(t, cli.createVenvCalled) + assert.False(t, cli.installReqCalled) +} + +func TestPythonPrepare_NoProjectFile(t *testing.T) { + dir := t.TempDir() + cli := &mockPythonTools{} + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{ + BoundaryDir: dir, + Cwd: dir, + } + scriptPath := filepath.Join(dir, "hook.py") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + assert.False(t, cli.createVenvCalled) + assert.False(t, cli.installReqCalled) + assert.False(t, cli.installProjCalled) + assert.Empty(t, e.venvPath) +} + +func TestPythonPrepare_WithRequirementsTxt(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + hooksDir := filepath.Join(projectDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0o700)) + writeFile( + t, + filepath.Join(projectDir, "requirements.txt"), + "flask\n", + ) + + cli := &mockPythonTools{} + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{BoundaryDir: root} + scriptPath := filepath.Join(hooksDir, "deploy.py") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + + // Virtual environment should be created. + assert.True(t, cli.createVenvCalled) + assert.Equal(t, projectDir, cli.venvDir) + assert.Equal(t, "myproject_env", cli.venvName) + + // Requirements should be installed. + assert.True(t, cli.installReqCalled) + assert.Equal(t, projectDir, cli.reqDir) + assert.Equal(t, "myproject_env", cli.reqVenv) + assert.Equal(t, "requirements.txt", cli.reqFile) + + // pyproject.toml path should NOT be used. + assert.False(t, cli.installProjCalled) + + // venvPath should be recorded. + expected := filepath.Join(projectDir, "myproject_env") + assert.Equal(t, expected, e.venvPath) +} + +func TestPythonPrepare_WithPyprojectToml(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + require.NoError(t, os.MkdirAll(projectDir, 0o700)) + writeFile( + t, + filepath.Join(projectDir, "pyproject.toml"), + "[project]\nname = \"demo\"\n", + ) + + cli := &mockPythonTools{} + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{BoundaryDir: root} + scriptPath := filepath.Join(projectDir, "deploy.py") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + + assert.True(t, cli.createVenvCalled) + assert.Equal(t, "myproject_env", cli.venvName) + + assert.True(t, cli.installProjCalled) + assert.Equal(t, projectDir, cli.projDir) + assert.Equal(t, "myproject_env", cli.projVenv) + + assert.False(t, cli.installReqCalled) +} + +func TestPythonPrepare_VenvAlreadyExists(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + require.NoError(t, os.MkdirAll(projectDir, 0o700)) + writeFile( + t, + filepath.Join(projectDir, "requirements.txt"), + "flask\n", + ) + + // Pre-create the venv directory to simulate an existing venv. + venvDir := filepath.Join(projectDir, "myproject_env") + require.NoError(t, os.MkdirAll(venvDir, 0o700)) + + cli := &mockPythonTools{} + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{BoundaryDir: root} + scriptPath := filepath.Join(projectDir, "deploy.py") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + assert.False( + t, cli.createVenvCalled, + "should skip venv creation when directory exists", + ) + assert.True( + t, cli.installReqCalled, + "should still install requirements", + ) + assert.NotEmpty(t, e.venvPath) +} + +// --------------------------------------------------------------------------- +// Execute tests +// --------------------------------------------------------------------------- + +func TestPythonExecute_WithVenv(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + hooksDir := filepath.Join(projectDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0o700)) + writeFile( + t, + filepath.Join(projectDir, "requirements.txt"), + "flask\n", + ) + + cli := &mockPythonTools{} + runner := &mockCommandRunner{} + e := newPythonExecutorInternal(runner, cli) + + execCtx := tools.ExecutionContext{ + BoundaryDir: root, + Cwd: projectDir, + } + + scriptPath := filepath.Join(hooksDir, "deploy.py") + require.NoError(t, e.Prepare(t.Context(), scriptPath, execCtx)) + + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + // The command should use the venv's Python binary. + venvBase := filepath.Join(projectDir, "myproject_env") + if runtime.GOOS == "windows" { + assert.Equal(t, + filepath.Join( + venvBase, "Scripts", "python.exe", + ), + runner.lastRunArgs.Cmd, + ) + } else { + assert.Equal(t, + filepath.Join(venvBase, "bin", "python"), + runner.lastRunArgs.Cmd, + ) + } + + // Script path should be passed as an argument. + require.Len(t, runner.lastRunArgs.Args, 1) + assert.Equal(t, scriptPath, runner.lastRunArgs.Args[0]) +} + +func TestPythonExecute_WithoutVenv(t *testing.T) { + dir := t.TempDir() + runner := &mockCommandRunner{} + e := newPythonExecutorInternal(runner, &mockPythonTools{}) + + execCtx := tools.ExecutionContext{BoundaryDir: dir} + scriptPath := filepath.Join(dir, "hook.py") + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + // With no venv, system Python should be used. + if runtime.GOOS == "windows" { + // Default mock returns nil for ToolInPath → "py". + assert.Equal(t, "py", runner.lastRunArgs.Cmd) + } else { + assert.Equal(t, "python3", runner.lastRunArgs.Cmd) + } +} + +func TestPythonExecute_EnvVarsPassthrough(t *testing.T) { + runner := &mockCommandRunner{} + envVars := []string{"FOO=bar", "BAZ=qux"} + e := newPythonExecutorInternal(runner, &mockPythonTools{}) + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + EnvVars: envVars, + } + scriptPath := filepath.Join(t.TempDir(), "hook.py") + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + assert.Equal(t, envVars, runner.lastRunArgs.Env) +} + +func TestPythonExecute_InteractiveMode(t *testing.T) { + runner := &mockCommandRunner{} + e := newPythonExecutorInternal(runner, &mockPythonTools{}) + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + Interactive: new(true), + } + scriptPath := filepath.Join(t.TempDir(), "hook.py") + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + assert.True(t, runner.lastRunArgs.Interactive) +} + +func TestPythonExecute_WorkingDirectory(t *testing.T) { + t.Run("ConfiguredCwd", func(t *testing.T) { + customCwd := filepath.Join(t.TempDir(), "custom") + require.NoError(t, os.MkdirAll(customCwd, 0o700)) + + runner := &mockCommandRunner{} + e := newPythonExecutorInternal(runner, &mockPythonTools{}) + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + Cwd: customCwd, + } + scriptPath := filepath.Join(t.TempDir(), "hook.py") + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + assert.Equal(t, customCwd, runner.lastRunArgs.Cwd) + }) + + t.Run("FallbackToScriptDir", func(t *testing.T) { + runner := &mockCommandRunner{} + e := newPythonExecutorInternal(runner, &mockPythonTools{}) + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + // Cwd intentionally empty + } + scriptDir := filepath.Join(t.TempDir(), "scripts") + scriptPath := filepath.Join(scriptDir, "hook.py") + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + assert.Equal(t, scriptDir, runner.lastRunArgs.Cwd) + }) +} diff --git a/cli/azd/pkg/tools/powershell/powershell.go b/cli/azd/pkg/tools/powershell/powershell.go index 718b6c7ce7b..598dfb09b84 100644 --- a/cli/azd/pkg/tools/powershell/powershell.go +++ b/cli/azd/pkg/tools/powershell/powershell.go @@ -7,7 +7,6 @@ import ( "context" "fmt" "runtime" - "strings" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/exec" @@ -15,76 +14,74 @@ import ( "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 { - return &powershellScript{ +// NewExecutor creates a PowerShell HookExecutor. Takes only IoC-injectable deps. +func NewExecutor(commandRunner exec.CommandRunner) tools.HookExecutor { + return &powershellExecutor{ commandRunner: commandRunner, - cwd: cwd, - envVars: envVars, + shellCmd: "pwsh", // default, resolved in Prepare } } -type powershellScript struct { +type powershellExecutor struct { commandRunner exec.CommandRunner - cwd string - envVars []string + shellCmd string // resolved in Prepare: "pwsh" or "powershell" } -func (ps *powershellScript) checkPath(options tools.ExecOptions) error { - return ps.commandRunner.ToolInPath(strings.Split(options.UserPwsh, " ")[0]) -} - -// Executes the specified powershell script -// When interactive is true will attach to stdin, stdout & stderr -func (ps *powershellScript) Execute(ctx context.Context, path string, options tools.ExecOptions) (exec.RunResult, error) { - noPwshError := ps.checkPath(options) - if noPwshError != nil { +// Prepare validates that PowerShell is available. Tries pwsh first, +// falls back to powershell on Windows. Returns an error with install +// guidance if neither is found. +func (p *powershellExecutor) Prepare( + _ context.Context, _ string, _ tools.ExecutionContext, +) error { + // Try pwsh first. + if p.commandRunner.ToolInPath("pwsh") == nil { + p.shellCmd = "pwsh" + return nil + } - if runtime.GOOS != "windows" { - return exec.RunResult{}, &internal.ErrorWithSuggestion{ - Err: noPwshError, - 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")), - } + // On Windows, fall back to powershell (PS5). + if runtime.GOOS == "windows" { + if p.commandRunner.ToolInPath("powershell") == nil { + p.shellCmd = "powershell" + return nil } - - options.UserPwsh = "powershell" - if err := ps.checkPath(options); err != nil { - return exec.RunResult{}, &internal.ErrorWithSuggestion{ - Err: err, - Suggestion: fmt.Sprintf( - "Make sure pwsh (PowerShell 7) or powershell (PowerShell 5) is installed on your system, visit %s", - output.WithLinkFormat("https://learn.microsoft.com/powershell/scripting/install/installing-powershell")), - } + return &internal.ErrorWithSuggestion{ + Err: fmt.Errorf("neither pwsh nor powershell found in PATH"), + Suggestion: fmt.Sprintf( + "Make sure pwsh (PowerShell 7) or powershell (PowerShell 5) is installed. Visit %s", + output.WithLinkFormat( + "https://learn.microsoft.com/powershell/scripting/install/installing-powershell", + )), } } - runArgs := exec.NewRunArgs(options.UserPwsh, path). - WithCwd(ps.cwd). - WithEnv(ps.envVars). - WithShell(true) - - if options.Interactive != nil { - runArgs = runArgs.WithInteractive(*options.Interactive) + // Non-Windows: pwsh is required. + return &internal.ErrorWithSuggestion{ + Err: fmt.Errorf("pwsh not found in PATH"), + Suggestion: fmt.Sprintf( + "PowerShell 7 is not installed or not in the path. Visit %s", + output.WithLinkFormat( + "https://learn.microsoft.com/powershell/scripting/install/installing-powershell", + )), } +} - if options.StdOut != nil { - runArgs = runArgs.WithStdOut(options.StdOut) +// Execute runs the PowerShell script using the shell resolved in Prepare. +func (p *powershellExecutor) Execute( + ctx context.Context, path string, execCtx tools.ExecutionContext, +) (exec.RunResult, error) { + runArgs := exec.NewRunArgs(p.shellCmd, path). + WithCwd(execCtx.Cwd). + WithEnv(execCtx.EnvVars). + WithShell(true) + + if execCtx.Interactive != nil { + runArgs = runArgs.WithInteractive(*execCtx.Interactive) } - result, err := ps.commandRunner.Run(ctx, runArgs) - if err != nil { - if noPwshError != nil { - err = &internal.ErrorWithSuggestion{ - Err: err, - Suggestion: fmt.Sprintf("pwsh (PowerShell 7) was not found and powershell (PowerShell 5) was automatically"+ - " used instead. You can try installing pwsh and trying again in case this script is not compatible "+ - "with PowerShell 5. See: %s", - output.WithLinkFormat("https://learn.microsoft.com/powershell/scripting/install/installing-powershell")), - } - } + if execCtx.StdOut != nil { + runArgs = runArgs.WithStdOut(execCtx.StdOut) } - return result, err + return p.commandRunner.Run(ctx, runArgs) } diff --git a/cli/azd/pkg/tools/powershell/powershell_test.go b/cli/azd/pkg/tools/powershell/powershell_test.go index 88b41e233cc..af0f14c32ee 100644 --- a/cli/azd/pkg/tools/powershell/powershell_test.go +++ b/cli/azd/pkg/tools/powershell/powershell_test.go @@ -8,77 +8,80 @@ import ( "errors" "fmt" "runtime" - "strings" "testing" + "github.com/azure/azure-dev/cli/azd/internal" "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" "github.com/stretchr/testify/require" ) -func Test_Powershell_Execute(t *testing.T) { - workingDir := "cwd" - scriptPath := "path/script.ps1" - env := []string{ - "a=apple", - "b=banana", - } +func Test_Powershell_Prepare(t *testing.T) { + emptyCtx := tools.ExecutionContext{} - t.Run("Success", func(t *testing.T) { + t.Run("PwshAvailable", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) - - // Mock ToolInPath to simulate pwsh being available mockContext.CommandRunner.MockToolInPath("pwsh", nil) - // #nosec G101 - userPwsh := "pwsh -NoProfile" - mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { - return strings.Contains(args.Cmd, userPwsh) - }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { - require.Equal(t, userPwsh, args.Cmd) - require.Equal(t, workingDir, args.Cwd) - require.Equal(t, scriptPath, args.Args[0]) - require.Equal(t, env, args.Env) + ps := NewExecutor(mockContext.CommandRunner) + err := ps.Prepare(*mockContext.Context, "script.ps1", emptyCtx) - return exec.NewRunResult(0, "", ""), nil - }) + require.NoError(t, err) + }) - powershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) - runResult, err := powershellScript.Execute( - *mockContext.Context, - scriptPath, - tools.ExecOptions{UserPwsh: userPwsh, Interactive: new(true)}, + t.Run("PwshNotAvailableFallbackWindows", func(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("pwsh fallback to powershell is only for Windows") + } + + mockContext := mocks.NewMockContext(context.Background()) + mockContext.CommandRunner.MockToolInPath( + "pwsh", fmt.Errorf("pwsh: command not found"), ) + mockContext.CommandRunner.MockToolInPath("powershell", nil) + + ps := NewExecutor(mockContext.CommandRunner) + err := ps.Prepare(*mockContext.Context, "script.ps1", emptyCtx) - require.NotNil(t, runResult) require.NoError(t, err) }) - t.Run("Success - alternative", func(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip("pwsh alternative is only for Windows") - } + t.Run("NoPowerShellInstalled", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + mockContext.CommandRunner.MockToolInPath( + "pwsh", errors.New("pwsh: command not found"), + ) + mockContext.CommandRunner.MockToolInPath( + "powershell", errors.New("powershell: command not found"), + ) - // #nosec G101 - userPwsh := "pwsh -NoProfile" - mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { - return strings.Contains(args.Cmd, userPwsh) - }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { - require.Equal(t, userPwsh, args.Cmd) - require.Equal(t, workingDir, args.Cwd) - require.Equal(t, scriptPath, args.Args[0]) - require.Equal(t, env, args.Env) + ps := NewExecutor(mockContext.CommandRunner) + err := ps.Prepare(*mockContext.Context, "script.ps1", emptyCtx) - return exec.NewRunResult(1, "not found", "not found"), nil - }) + require.Error(t, err) + if sugErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err); ok { + require.Contains(t, sugErr.Suggestion, "powershell/scripting/install") + } + }) +} + +func Test_Powershell_Execute(t *testing.T) { + workingDir := "cwd" + scriptPath := "path/script.ps1" + env := []string{ + "a=apple", + "b=banana", + } + + t.Run("Success", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + mockContext.CommandRunner.MockToolInPath("pwsh", nil) - userPwshAlternative := "powershell" mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { - return strings.Contains(args.Cmd, userPwshAlternative) + return args.Cmd == "pwsh" }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { - require.Equal(t, userPwshAlternative, args.Cmd) + require.Equal(t, "pwsh", args.Cmd) require.Equal(t, workingDir, args.Cwd) require.Equal(t, scriptPath, args.Args[0]) require.Equal(t, env, args.Env) @@ -86,14 +89,15 @@ func Test_Powershell_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - // Mock ToolInPath to simulate pwsh being available - mockContext.CommandRunner.MockToolInPath("pwsh", fmt.Errorf("failed to find PowerShell executable")) + ps := NewExecutor(mockContext.CommandRunner) + execCtx := tools.ExecutionContext{Cwd: workingDir, EnvVars: env} + require.NoError(t, ps.Prepare(*mockContext.Context, scriptPath, execCtx)) - powershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) - runResult, err := powershellScript.Execute( + execCtx.Interactive = new(true) + runResult, err := ps.Execute( *mockContext.Context, scriptPath, - tools.ExecOptions{UserPwsh: userPwsh, Interactive: new(true)}, + execCtx, ) require.NotNil(t, runResult) @@ -102,8 +106,6 @@ func Test_Powershell_Execute(t *testing.T) { t.Run("Error", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) - - // Mock ToolInPath to simulate pwsh being available mockContext.CommandRunner.MockToolInPath("pwsh", nil) mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { @@ -112,47 +114,36 @@ func Test_Powershell_Execute(t *testing.T) { return exec.NewRunResult(1, "", "error message"), errors.New("error message") }) - powershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) - runResult, err := powershellScript.Execute( - *mockContext.Context, - scriptPath, - tools.ExecOptions{UserPwsh: "pwsh", Interactive: new(true)}, - ) + ps := NewExecutor(mockContext.CommandRunner) + execCtx := tools.ExecutionContext{Cwd: workingDir, EnvVars: env} + require.NoError(t, ps.Prepare(*mockContext.Context, scriptPath, execCtx)) - require.Equal(t, 1, runResult.ExitCode) - require.Error(t, err) - }) - - t.Run("NoPowerShellInstalled", func(t *testing.T) { - mockContext := mocks.NewMockContext(context.Background()) - - // Mock ToolInPath to simulate any powershell version not being available - mockContext.CommandRunner.MockToolInPath("pwsh", errors.New("pwsh: command not found")) - mockContext.CommandRunner.MockToolInPath("powershell", errors.New("powershell: command not found")) - - powershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) - _, err := powershellScript.Execute( + execCtx.Interactive = new(true) + runResult, err := ps.Execute( *mockContext.Context, scriptPath, - tools.ExecOptions{UserPwsh: "pwsh", Interactive: new(true)}, + execCtx, ) + require.Equal(t, 1, runResult.ExitCode) require.Error(t, err) }) tests := []struct { name string - value tools.ExecOptions + value tools.ExecutionContext }{ - {name: "Interactive", value: tools.ExecOptions{UserPwsh: "pwsh", Interactive: new(true)}}, - {name: "NonInteractive", value: tools.ExecOptions{UserPwsh: "pwsh", Interactive: new(false)}}, + {name: "Interactive", value: tools.ExecutionContext{ + Cwd: workingDir, EnvVars: env, Interactive: new(true), + }}, + {name: "NonInteractive", value: tools.ExecutionContext{ + Cwd: workingDir, EnvVars: env, Interactive: new(false), + }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) - - // Mock ToolInPath to simulate pwsh being available mockContext.CommandRunner.MockToolInPath("pwsh", nil) mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { @@ -162,8 +153,11 @@ func Test_Powershell_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - powershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) - runResult, err := powershellScript.Execute(*mockContext.Context, scriptPath, test.value) + ps := NewExecutor(mockContext.CommandRunner) + execCtx := tools.ExecutionContext{Cwd: workingDir, EnvVars: env} + require.NoError(t, ps.Prepare(*mockContext.Context, scriptPath, execCtx)) + + runResult, err := ps.Execute(*mockContext.Context, scriptPath, test.value) require.NotNil(t, runResult) require.NoError(t, err) diff --git a/cli/azd/pkg/tools/script.go b/cli/azd/pkg/tools/script.go index 2526b78bdf3..f6f2c280eab 100644 --- a/cli/azd/pkg/tools/script.go +++ b/cli/azd/pkg/tools/script.go @@ -10,14 +10,39 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/exec" ) -// ExecOptions provide configuration for how scripts are executed -type ExecOptions struct { +// ExecutionContext provides the per-invocation execution environment +// for a hook. The HooksRunner constructs this from the validated +// HookConfig, resolving secrets, merging directories, and building +// the environment. +type ExecutionContext struct { + // Cwd is the working directory for execution. + Cwd string + + // EnvVars is the merged environment for the process, including + // resolved secrets and the azd environment. + EnvVars []string + + // BoundaryDir is the project or service root directory. + // Language executors walk upward from the script to BoundaryDir + // to discover dependency files (requirements.txt, package.json). + BoundaryDir string + + // Interactive controls whether stdin is attached. Interactive *bool - StdOut io.Writer - UserPwsh string + + // StdOut overrides the default stdout for the process. + StdOut io.Writer } -// Utility to easily execute a bash script across platforms -type Script interface { - Execute(ctx context.Context, scriptPath string, options ExecOptions) (exec.RunResult, error) +// HookExecutor is the unified interface for all hook execution. +// Every executor follows a two-phase lifecycle: +// 1. Prepare — validate prerequisites, resolve tools, install dependencies +// 2. Execute — run the hook +type HookExecutor interface { + // Prepare performs pre-execution setup such as runtime validation, + // virtual environment creation, or dependency installation. + Prepare(ctx context.Context, scriptPath string, execCtx ExecutionContext) error + + // Execute runs the hook at the given path. + Execute(ctx context.Context, scriptPath string, execCtx ExecutionContext) (exec.RunResult, error) } diff --git a/cli/azd/resources/error_suggestions.yaml b/cli/azd/resources/error_suggestions.yaml index 5dc30847f2f..e2b6c15f612 100644 --- a/cli/azd/resources/error_suggestions.yaml +++ b/cli/azd/resources/error_suggestions.yaml @@ -461,6 +461,43 @@ rules: message: "The Azure authentication session may have expired." suggestion: "Run 'azd auth login' to refresh your credentials, then retry." + # ============================================================================ + # Python Hook Failures + # Language hooks (Phase 1: Python) — dependency and runtime errors + # ============================================================================ + + - patterns: + - "python 3 is required to run this hook" + - "python is not installed" + message: "Python 3 is required to run a language hook but was not found." + suggestion: "Install Python 3 from https://www.python.org/downloads/ and ensure it is on your PATH." + links: + - url: "https://www.python.org/downloads/" + title: "Python Downloads" + + - patterns: + - "creating python virtual environment" + - "venv module" + message: "Failed to create a Python virtual environment for a hook script." + suggestion: >- + Ensure Python 3.3+ is installed with the venv module. On Debian/Ubuntu, + you may need to install it separately with 'sudo apt install python3-venv'. + + - patterns: + - "installing python requirements" + - "installing python project" + message: "Failed to install Python dependencies for a hook script." + suggestion: >- + Check that your requirements.txt or pyproject.toml is valid and all packages + are available. Run 'pip install -r requirements.txt' manually to diagnose the issue. + + - patterns: + - "inline scripts are not supported for" + message: "Inline scripts are only supported for shell hooks (sh, pwsh)." + suggestion: >- + Write your script to a file and set 'run' to the file path + (e.g. run: ./hooks/my-script.py). Language hooks require a file path. + # ============================================================================ # Subscription Errors # ============================================================================ diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index 62787b39245..a1eaf79c581 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -827,6 +827,24 @@ ], "default": "sh" }, + "language": { + "type": "string", + "title": "Programming language of the hook script", + "description": "Optional. Specifies the programming language used to execute the hook script. When omitted, the language is auto-detected from the file extension of the 'run' path (e.g. .py → python, .ps1 → pwsh). Shell languages (sh, pwsh) use the existing shell runner; other languages use a language-specific executor that handles dependency installation and runtime management.", + "enum": [ + "sh", + "pwsh", + "js", + "ts", + "python", + "dotnet" + ] + }, + "dir": { + "type": "string", + "title": "Working directory for language hook execution", + "description": "Optional. Specifies the working directory for language hook execution. Used as the project root for dependency installation (e.g. pip install from requirements.txt) and as the working directory when running the script. Relative paths are resolved from the project or service root. When omitted, defaults to the directory containing the script file." + }, "run": { "type": "string", "title": "Required. The inline script or relative path of your scripts from the project or service path", @@ -890,6 +908,8 @@ "properties": { "run": false, "shell": false, + "language": false, + "dir": false, "interactive": false, "continueOnError": false, "secrets": false @@ -918,6 +938,16 @@ "required": [ "shell" ] + }, + { + "required": [ + "language" + ] + }, + { + "required": [ + "dir" + ] } ] }, diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index 31f2de6c77b..c61cb910e26 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -787,6 +787,24 @@ ], "default": "sh" }, + "language": { + "type": "string", + "title": "Programming language of the hook script", + "description": "Optional. Specifies the programming language used to execute the hook script. When omitted, the language is auto-detected from the file extension of the 'run' path (e.g. .py → python, .ps1 → pwsh). Shell languages (sh, pwsh) use the existing shell runner; other languages use a language-specific executor that handles dependency installation and runtime management.", + "enum": [ + "sh", + "pwsh", + "js", + "ts", + "python", + "dotnet" + ] + }, + "dir": { + "type": "string", + "title": "Working directory for language hook execution", + "description": "Optional. Specifies the working directory for language hook execution. Used as the project root for dependency installation (e.g. pip install from requirements.txt) and as the working directory when running the script. Relative paths are resolved from the project or service root. When omitted, defaults to the directory containing the script file." + }, "run": { "type": "string", "title": "Required. The inline script or relative path of your scripts from the project or service path", @@ -850,6 +868,8 @@ "properties": { "run": false, "shell": false, + "language": false, + "dir": false, "interactive": false, "continueOnError": false, "secrets": false @@ -878,6 +898,16 @@ "required": [ "shell" ] + }, + { + "required": [ + "language" + ] + }, + { + "required": [ + "dir" + ] } ] },