From 8aab3b1afe0e9060cbde1c635f5c178ffbc42602 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 1 Apr 2026 18:23:12 -0700 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20add=20multi-language=20hook=20suppo?= =?UTF-8?q?rt=20(Phase=201=20=E2=80=94=20Python)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the azd hook system to support programming language hooks beyond bash and PowerShell. Phase 1 delivers the extensible framework and Python support. New capabilities: - ScriptExecutor interface for language-based hook execution - Auto-detect language from file extension (.py, .js, .ts, .cs) - Optional explicit language: and dir: fields in azure.yaml - Python executor with virtual environment and pip dependency management - Project file discovery (walks up from script to find requirements.txt) - Early runtime validation with actionable install guidance - Full backwards compatibility with existing shell hooks New files: - pkg/tools/language/executor.go — ScriptExecutor interface, ScriptLanguage type - pkg/tools/language/python_executor.go — Python lifecycle (venv, pip, execute) - pkg/tools/language/project_discovery.go — Walk-up project file finder - docs/language-hooks.md — Feature documentation Modified files: - pkg/ext/models.go — HookConfig Language/Dir fields, language resolution - pkg/ext/hooks_runner.go — Language hook execution pipeline - pkg/ext/hooks_manager.go — Runtime validation - schemas/v1.0/azure.yaml.json — Schema updates - schemas/alpha/azure.yaml.json — Schema updates - resources/error_suggestions.yaml — Python hook error rules Contributes to #4384. Complements #7423. Extends patterns from #3613, #4740, #4560. Motivated by research in #3978. Extends detection from #1579. Part of #7435. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cover | 32 + cli/azd/docs/language-hooks.md | 144 +++ cli/azd/pkg/ext/hooks_manager.go | 135 +++ cli/azd/pkg/ext/hooks_manager_test.go | 201 ++++ cli/azd/pkg/ext/hooks_runner.go | 264 ++++- cli/azd/pkg/ext/hooks_runner_test.go | 329 ++++++ cli/azd/pkg/ext/models.go | 140 ++- cli/azd/pkg/ext/models_test.go | 322 ++++++ cli/azd/pkg/ext/python_hooks_e2e_test.go | 947 ++++++++++++++++++ cli/azd/pkg/tools/language/executor.go | 152 +++ cli/azd/pkg/tools/language/executor_test.go | 225 +++++ .../pkg/tools/language/project_discovery.go | 140 +++ .../tools/language/project_discovery_test.go | 231 +++++ cli/azd/pkg/tools/language/python_executor.go | 278 +++++ .../tools/language/python_executor_test.go | 366 +++++++ cli/azd/resources/error_suggestions.yaml | 37 + schemas/alpha/azure.yaml.json | 30 + schemas/v1.0/azure.yaml.json | 30 + 18 files changed, 3962 insertions(+), 41 deletions(-) create mode 100644 cli/azd/cover create mode 100644 cli/azd/docs/language-hooks.md create mode 100644 cli/azd/pkg/ext/models_test.go create mode 100644 cli/azd/pkg/ext/python_hooks_e2e_test.go create mode 100644 cli/azd/pkg/tools/language/executor.go create mode 100644 cli/azd/pkg/tools/language/executor_test.go create mode 100644 cli/azd/pkg/tools/language/project_discovery.go create mode 100644 cli/azd/pkg/tools/language/project_discovery_test.go create mode 100644 cli/azd/pkg/tools/language/python_executor.go create mode 100644 cli/azd/pkg/tools/language/python_executor_test.go 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..5145a8f96cb --- /dev/null +++ b/cli/azd/docs/language-hooks.md @@ -0,0 +1,144 @@ +# 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. This directory is +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. + +## 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 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 — 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 + +When the script's dependencies are in a different directory (e.g. a +subdirectory with its own `requirements.txt`), use `dir` to point to the +project root: + +```yaml +hooks: + postprovision: + run: ./hooks/data-tool/main.py + language: python + dir: ./hooks/data-tool +``` + +### 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..4f7ae7bd12d 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,137 @@ 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 + } + + // 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..7125762e964 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -5,12 +5,15 @@ package ext import ( "context" + "errors" "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" @@ -18,7 +21,9 @@ import ( "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/language" "github.com/azure/azure-dev/cli/azd/pkg/tools/powershell" + "github.com/azure/azure-dev/cli/azd/pkg/tools/python" ) // Hooks enable support to invoke integration scripts before & after commands @@ -137,7 +142,9 @@ func (h *HooksRunner) GetScript(hookConfig *HookConfig, envVars []string) (tools } } -func (h *HooksRunner) execHook(ctx context.Context, hookConfig *HookConfig, options *tools.ExecOptions) error { +func (h *HooksRunner) execHook( + ctx context.Context, hookConfig *HookConfig, options *tools.ExecOptions, +) error { if options == nil { options = &tools.ExecOptions{} } @@ -166,28 +173,151 @@ 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. It must run before the language/shell branch below. + if err := hookConfig.validate(); err != nil { return err } - formatter := h.console.GetFormatter() - consoleInteractive := (formatter == nil || formatter.Kind() == output.NoneFormat) - scriptInteractive := consoleInteractive && hookConfig.Interactive + // Language hooks (Python, JS, TS, DotNet) are executed by a + // language-specific ScriptExecutor instead of a shell script. + if hookConfig.IsLanguageHook() { + return h.execLanguageHook( + ctx, hookConfig, hookEnv.Environ(), options, + ) + } - if options.Interactive == nil { - options.Interactive = &scriptInteractive + return h.execShellHook(ctx, hookConfig, hookEnv.Environ(), options) +} + +// execLanguageHook prepares and executes a programming-language hook +// via the [language.ScriptExecutor] pipeline. +func (h *HooksRunner) execLanguageHook( + ctx context.Context, + hookConfig *HookConfig, + envVars []string, + options *tools.ExecOptions, +) error { + // Determine the boundary directory for project file discovery. + boundaryDir := h.cwd + if hookConfig.cwd != "" { + boundaryDir = hookConfig.cwd } - // 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 + // Determine working directory: explicit Dir → script dir → cwd. + cwd := h.cwd + if hookConfig.Dir != "" { + cwd = hookConfig.Dir + } else if hookConfig.path != "" { + cwd = filepath.Dir( + filepath.Join(boundaryDir, hookConfig.path), + ) + } + + pythonCli := python.NewCli(h.commandRunner) + executor, err := language.GetExecutor( + hookConfig.Language, + h.commandRunner, + pythonCli, + boundaryDir, + cwd, + envVars, + ) + if err != nil { + if errors.Is(err, language.ErrUnsupportedLanguage) { + return &errorhandler.ErrorWithSuggestion{ + Err: fmt.Errorf( + "getting %s executor for hook '%s': %w", + hookConfig.Language, + hookConfig.Name, + err, + ), + Message: fmt.Sprintf( + "The '%s' language is not yet supported "+ + "for hook '%s'.", + hookConfig.Language, + hookConfig.Name, + ), + Suggestion: "Currently only Python hooks are " + + "supported. Use a shell script (sh/pwsh)" + + " or Python instead.", + } + } + return fmt.Errorf( + "getting %s executor for hook '%s': %w", + hookConfig.Language, hookConfig.Name, err, + ) + } + + scriptPath := hookConfig.path + if hookConfig.cwd != "" { + scriptPath = filepath.Join(hookConfig.cwd, hookConfig.path) + } + + log.Printf( + "Preparing %s hook '%s' (%s)\n", + hookConfig.Language, hookConfig.Name, scriptPath, + ) + + if err := executor.Prepare(ctx, scriptPath); 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 %s runtime is installed "+ + "and the script at '%s' is valid. "+ + "Check dependency files "+ + "(requirements.txt / pyproject.toml) "+ + "for errors.", + hookConfig.Language, + scriptPath, + ), + } + } + + if h.configureExecOptions(ctx, hookConfig, options) { + defer h.console.StopPreviewer(ctx, false) + } + + log.Printf( + "Executing %s hook '%s' (%s)\n", + hookConfig.Language, hookConfig.Name, scriptPath, + ) + + res, err := executor.Execute(ctx, scriptPath, *options) + if err != nil { + return h.handleHookError( + ctx, hookConfig, res, scriptPath, err, + ) + } + + return nil +} + +// execShellHook runs a hook through the existing bash/powershell +// shell script pipeline. This preserves the original behaviour for +// shell-based hooks. +func (h *HooksRunner) execShellHook( + ctx context.Context, + hookConfig *HookConfig, + envVars []string, + options *tools.ExecOptions, +) error { + script, err := h.GetScript(hookConfig, envVars) + if err != nil { + return err + } + + if h.configureExecOptions(ctx, hookConfig, options) { defer h.console.StopPreviewer(ctx, false) } options.UserPwsh = string(hookConfig.Shell) @@ -195,32 +325,96 @@ func (h *HooksRunner) execHook(ctx context.Context, hookConfig *HookConfig, opti log.Printf("Executing script '%s'\n", hookConfig.path) res, err := script.Execute(ctx, hookConfig.path, *options) 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, hookConfig.path, 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. + // Removing temp scripts only on success to support better + // debugging with failing scripts. if hookConfig.location == ScriptLocationInline { defer os.Remove(hookConfig.path) } return nil } + +// configureExecOptions 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) configureExecOptions( + ctx context.Context, + hookConfig *HookConfig, + options *tools.ExecOptions, +) bool { + formatter := h.console.GetFormatter() + consoleInteractive := (formatter == nil || + formatter.Kind() == output.NoneFormat) + scriptInteractive := consoleInteractive && hookConfig.Interactive + + if options.Interactive == nil { + options.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 !*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 + 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..72f115309e0 100644 --- a/cli/azd/pkg/ext/hooks_runner_test.go +++ b/cli/azd/pkg/ext/hooks_runner_test.go @@ -5,7 +5,9 @@ package ext import ( "context" + "fmt" "os" + "path/filepath" "reflect" "strings" "testing" @@ -418,6 +420,333 @@ func Test_Hooks_GetScript(t *testing.T) { } +// Test_ExecHook_LanguageHooks verifies the integration between +// [HooksRunner] and [language.ScriptExecutor] 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"), + }, + }, + } + + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) + + prepareRan := false + executeRan := false + + mockContext := mocks.NewMockContext(context.Background()) + + // 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, + 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, prepareRan, "Prepare (version check) should have run") + require.True(t, executeRan, "Execute should have run the .py script") + }) + + 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()) + 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, + 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, "Shell script path should be used for .sh hooks") + }) + + 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) + + mockContext := mocks.NewMockContext(context.Background()) + + // 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, + mockContext.CommandRunner, + envManager, + mockContext.Console, + cwd, + hooksMap, + env, + mockContext.Container, + ) + + err := runner.RunHooks( + *mockContext.Context, HookTypePre, nil, "deploy", + ) + + require.Error(t, err) + require.Contains(t, err.Error(), "preparing python hook") + }) + + 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) + + mockContext := mocks.NewMockContext(context.Background()) + + // 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, + mockContext.CommandRunner, + envManager, + mockContext.Console, + cwd, + hooksMap, + env, + mockContext.Container, + ) + + err := runner.RunHooks( + *mockContext.Context, HookTypePre, nil, "deploy", + ) + + 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) + + env := environment.NewWithValues("test", map[string]string{ + "MY_VAR": "my_value", + "OTHER_VAR": "other_value", + }) + + 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) + + var capturedEnv []string + + mockContext := mocks.NewMockContext(context.Background()) + + // 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 + }) + + // 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 +} + type scriptValidationTest struct { name string config *HookConfig diff --git a/cli/azd/pkg/ext/models.go b/cli/azd/pkg/ext/models.go index 91a12b96495..f0596cf7dbd 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 [language.ScriptExecutor] pipeline. type HookConfig struct { // The location of the script hook (file path or inline) location ScriptLocation @@ -66,6 +93,21 @@ 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 an optional working directory for language hook + // execution. It is used as the project root for dependency + // installation (e.g. pip install) and as the cwd when running the + // script. Relative paths are resolved from the project or service + // root. When empty, defaults to the directory containing the + // script file. + 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 +123,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 +152,47 @@ 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() { + 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 behaviour (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 +225,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 +266,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 +// [language.ScriptExecutor] 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 +306,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..23177745639 --- /dev/null +++ b/cli/azd/pkg/ext/models_test.go @@ -0,0 +1,322 @@ +// 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\nlanguage: 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, 0o644) + 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_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..7efeefacc2e --- /dev/null +++ b/cli/azd/pkg/ext/python_hooks_e2e_test.go @@ -0,0 +1,947 @@ +// 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 ScriptExecutor 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()) + 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()) + 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()) + 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()) + 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()) + 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()) + 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()) + 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()) + 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()) + 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()) + 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(), + ) + 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()) + 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()) + 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()) + + 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/language/executor.go b/cli/azd/pkg/tools/language/executor.go new file mode 100644 index 00000000000..950a3eed548 --- /dev/null +++ b/cli/azd/pkg/tools/language/executor.go @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "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" +) + +// 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 — returns [ErrUnsupportedLanguage] from [GetExecutor]. + ScriptLanguageJavaScript ScriptLanguage = "js" + // ScriptLanguageTypeScript identifies TypeScript scripts (.ts files). + // Not yet supported — returns [ErrUnsupportedLanguage] from [GetExecutor]. + ScriptLanguageTypeScript ScriptLanguage = "ts" + // ScriptLanguagePython identifies Python scripts (.py files). + ScriptLanguagePython ScriptLanguage = "python" + // ScriptLanguageDotNet identifies .NET (C#) scripts (.cs files). + // Not yet supported — returns [ErrUnsupportedLanguage] from [GetExecutor]. + ScriptLanguageDotNet ScriptLanguage = "dotnet" +) + +// ErrUnsupportedLanguage is returned by [GetExecutor] when the +// requested [ScriptLanguage] is recognized but no [ScriptExecutor] +// implementation exists yet (e.g. JavaScript, TypeScript, DotNet). +var ErrUnsupportedLanguage = errors.New( + "language is not yet supported; supported languages: python. " + + "JavaScript, TypeScript, and .NET support is planned", +) + +// ErrShellLanguage is returned by [GetExecutor] when the caller +// requests an executor for a shell language (Bash or PowerShell). +// Shell scripts are handled by the existing shell script runner in +// [pkg/ext] and do not use the [ScriptExecutor] pipeline. +var ErrShellLanguage = errors.New( + "shell languages (sh, pwsh) are handled by the existing " + + "shell script runner, not the language executor pipeline", +) + +// ScriptExecutor defines the interface for language-specific hook +// script preparation and execution. +type ScriptExecutor interface { + // Language returns the script language this executor handles. + Language() ScriptLanguage + + // Prepare performs pre-execution steps such as runtime + // validation, dependency installation, or build steps. + Prepare(ctx context.Context, scriptPath string) error + + // Execute runs the script at the given path and returns the + // result. The signature is compatible with [tools.Script]. + Execute( + ctx context.Context, + scriptPath string, + options tools.ExecOptions, + ) (exec.RunResult, error) +} + +// 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 + } +} + +// GetExecutor returns a [ScriptExecutor] for the given language. +// +// Phase 1 supports only Python. JavaScript, TypeScript, and DotNet +// return [ErrUnsupportedLanguage]. Bash and PowerShell return +// [ErrShellLanguage] because they are handled by the existing shell +// script runner. +// +// The boundaryDir limits project file discovery during Prepare; cwd +// sets the working directory for script execution; envVars are +// forwarded to all child processes. +func GetExecutor( + lang ScriptLanguage, + commandRunner exec.CommandRunner, + pythonCli *python.Cli, + boundaryDir string, + cwd string, + envVars []string, +) (ScriptExecutor, error) { + switch lang { + case ScriptLanguagePython: + return newPythonExecutor( + commandRunner, pythonCli, + boundaryDir, cwd, envVars, + ), nil + case ScriptLanguageJavaScript, + ScriptLanguageTypeScript, + ScriptLanguageDotNet: + return nil, fmt.Errorf( + "%w: %s", ErrUnsupportedLanguage, lang, + ) + case ScriptLanguageBash, ScriptLanguagePowerShell: + return nil, fmt.Errorf( + "%w: %s", ErrShellLanguage, lang, + ) + default: + return nil, fmt.Errorf( + "unknown script language: %q", string(lang), + ) + } +} 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..3f84307d09d --- /dev/null +++ b/cli/azd/pkg/tools/language/executor_test.go @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "context" + "errors" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/tools/python" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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) + }) + } +} + +func TestGetExecutor(t *testing.T) { + mockRunner := &mockCommandRunner{} + pythonCli := python.NewCli(mockRunner) + + tests := []struct { + name string + language ScriptLanguage + wantErr error // sentinel error (checked via errors.Is) + wantErrMsg string // substring in error message + wantExec bool // true when a valid executor is expected + }{ + { + name: "PythonReturnsExecutor", + language: ScriptLanguagePython, + wantExec: true, + }, + { + name: "JavaScriptUnsupported", + language: ScriptLanguageJavaScript, + wantErr: ErrUnsupportedLanguage, + }, + { + name: "TypeScriptUnsupported", + language: ScriptLanguageTypeScript, + wantErr: ErrUnsupportedLanguage, + }, + { + name: "DotNetUnsupported", + language: ScriptLanguageDotNet, + wantErr: ErrUnsupportedLanguage, + }, + { + name: "BashShellLanguage", + language: ScriptLanguageBash, + wantErr: ErrShellLanguage, + }, + { + name: "PowerShellShellLanguage", + language: ScriptLanguagePowerShell, + wantErr: ErrShellLanguage, + }, + { + name: "UnknownReturnsError", + language: ScriptLanguageUnknown, + wantErrMsg: "unknown script language", + }, + { + name: "ArbitraryStringReturnsError", + language: ScriptLanguage("ruby"), + wantErrMsg: "unknown script language", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + executor, err := GetExecutor( + tt.language, mockRunner, pythonCli, + "", "", nil, + ) + + switch { + case tt.wantErr != nil: + require.Error(t, err) + assert.True( + t, + errors.Is(err, tt.wantErr), + "expected error %v, got %v", + tt.wantErr, err, + ) + assert.Nil(t, executor) + case tt.wantErrMsg != "": + require.Error(t, err) + assert.Contains( + t, err.Error(), tt.wantErrMsg, + ) + assert.Nil(t, executor) + default: + require.NoError(t, err) + } + + if tt.wantExec { + require.NotNil(t, executor) + assert.Equal( + t, tt.language, executor.Language(), + ) + } + }) + } +} + +// mockCommandRunner is a minimal mock of [exec.CommandRunner] +// used to construct test dependencies without invoking real +// processes. Optional function fields allow tests to customize +// behavior when the zero-value defaults are insufficient. +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 +} 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..0c0e51f5b6e --- /dev/null +++ b/cli/azd/pkg/tools/language/project_discovery.go @@ -0,0 +1,140 @@ +// 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 the project files to search for, in +// priority order. The first match found in a directory wins. +var knownProjectFiles = []projectFileEntry{ + {Name: "requirements.txt", Language: ScriptLanguagePython}, + {Name: "pyproject.toml", 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..9e55f22fc07 --- /dev/null +++ b/cli/azd/pkg/tools/language/project_discovery_test.go @@ -0,0 +1,231 @@ +// 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_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..75483215f6b --- /dev/null +++ b/cli/azd/pkg/tools/language/python_executor.go @@ -0,0 +1,278 @@ +// 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" +) + +// 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 [ScriptExecutor] 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 + boundaryDir string // project/service root for discovery + cwd string // working directory for execution + envVars []string // environment variables for execution + + // venvPath is set by Prepare when a project context with a + // dependency file is discovered. Empty means system Python. + venvPath string +} + +// newPythonExecutor creates a pythonExecutor configured for the +// given execution context. The boundaryDir limits project file +// discovery; cwd sets the working directory for script execution; +// envVars are forwarded to all child processes. +func newPythonExecutor( + commandRunner exec.CommandRunner, + pythonCli pythonTools, + boundaryDir string, + cwd string, + envVars []string, +) *pythonExecutor { + return &pythonExecutor{ + commandRunner: commandRunner, + pythonCli: pythonCli, + boundaryDir: boundaryDir, + cwd: cwd, + envVars: envVars, + } +} + +// Language returns [ScriptLanguagePython]. +func (e *pythonExecutor) Language() ScriptLanguage { + return ScriptLanguagePython +} + +// 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, +) 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, e.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, + ); 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, + ); 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, +) 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, e.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, +) error { + switch depFile { + case "requirements.txt": + if err := e.pythonCli.InstallRequirements( + ctx, projectDir, venvName, depFile, e.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, e.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, + options tools.ExecOptions, +) (exec.RunResult, error) { + pyCmd := e.resolvePythonPath() + + runArgs := exec. + NewRunArgs(pyCmd, scriptPath). + WithEnv(e.envVars) + + // Prefer configured cwd; fall back to script's directory. + cwd := e.cwd + if cwd == "" { + cwd = filepath.Dir(scriptPath) + } + runArgs = runArgs.WithCwd(cwd) + + if options.Interactive != nil { + runArgs = runArgs.WithInteractive( + *options.Interactive, + ) + } + if options.StdOut != nil { + runArgs = runArgs.WithStdOut(options.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" { + if commandRunner.ToolInPath("py") == nil { + return "py" + } + return "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..a5445b08809 --- /dev/null +++ b/cli/azd/pkg/tools/language/python_executor_test.go @@ -0,0 +1,366 @@ +// 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/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 +} + +// --------------------------------------------------------------------------- +// Prepare tests +// --------------------------------------------------------------------------- + +func TestPythonPrepare_PythonNotInstalled(t *testing.T) { + cli := &mockPythonTools{ + checkInstalledErr: errors.New("python not found"), + } + e := newPythonExecutor( + &mockCommandRunner{}, cli, t.TempDir(), "", nil, + ) + + err := e.Prepare(t.Context(), "/any/hook.py") + + 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 := newPythonExecutor( + &mockCommandRunner{}, cli, dir, dir, nil, + ) + + scriptPath := filepath.Join(dir, "hook.py") + err := e.Prepare(t.Context(), scriptPath) + + 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 := newPythonExecutor( + &mockCommandRunner{}, cli, root, "", nil, + ) + + scriptPath := filepath.Join(hooksDir, "deploy.py") + err := e.Prepare(t.Context(), scriptPath) + + 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 := newPythonExecutor( + &mockCommandRunner{}, cli, root, "", nil, + ) + + scriptPath := filepath.Join(projectDir, "deploy.py") + err := e.Prepare(t.Context(), scriptPath) + + 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 := newPythonExecutor( + &mockCommandRunner{}, cli, root, "", nil, + ) + + scriptPath := filepath.Join(projectDir, "deploy.py") + err := e.Prepare(t.Context(), scriptPath) + + 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 := newPythonExecutor( + runner, cli, root, projectDir, nil, + ) + + scriptPath := filepath.Join(hooksDir, "deploy.py") + require.NoError(t, e.Prepare(t.Context(), scriptPath)) + + _, err := e.Execute( + t.Context(), scriptPath, tools.ExecOptions{}, + ) + 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 := newPythonExecutor( + runner, &mockPythonTools{}, dir, "", nil, + ) + + scriptPath := filepath.Join(dir, "hook.py") + _, err := e.Execute( + t.Context(), scriptPath, tools.ExecOptions{}, + ) + 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 := newPythonExecutor( + runner, &mockPythonTools{}, + t.TempDir(), "", envVars, + ) + + scriptPath := filepath.Join(t.TempDir(), "hook.py") + _, err := e.Execute( + t.Context(), scriptPath, tools.ExecOptions{}, + ) + require.NoError(t, err) + + assert.Equal(t, envVars, runner.lastRunArgs.Env) +} + +func TestPythonExecute_InteractiveMode(t *testing.T) { + runner := &mockCommandRunner{} + e := newPythonExecutor( + runner, &mockPythonTools{}, + t.TempDir(), "", nil, + ) + + scriptPath := filepath.Join(t.TempDir(), "hook.py") + _, err := e.Execute( + t.Context(), scriptPath, + tools.ExecOptions{Interactive: new(true)}, + ) + 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 := newPythonExecutor( + runner, &mockPythonTools{}, + t.TempDir(), customCwd, nil, + ) + + scriptPath := filepath.Join(t.TempDir(), "hook.py") + _, err := e.Execute( + t.Context(), scriptPath, tools.ExecOptions{}, + ) + require.NoError(t, err) + + assert.Equal(t, customCwd, runner.lastRunArgs.Cwd) + }) + + t.Run("FallbackToScriptDir", func(t *testing.T) { + runner := &mockCommandRunner{} + e := newPythonExecutor( + runner, &mockPythonTools{}, + t.TempDir(), "", nil, // empty cwd + ) + + scriptDir := filepath.Join(t.TempDir(), "scripts") + scriptPath := filepath.Join(scriptDir, "hook.py") + _, err := e.Execute( + t.Context(), scriptPath, tools.ExecOptions{}, + ) + require.NoError(t, err) + + assert.Equal(t, scriptDir, runner.lastRunArgs.Cwd) + }) +} 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" + ] } ] }, From 601e0d4d0e10bf2efeeb58380f6e3efe31615c74 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 1 Apr 2026 18:43:18 -0700 Subject: [PATCH 2/9] fix: address CI lint failures (spelling, permissions, line length) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix British spelling "behaviour" → "behavior" in hooks_runner.go and models.go - Tighten WriteFile permissions from 0o644 to 0o600 in models_test.go (gosec G306) - Break long test line to stay within 125-char limit (lll) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/ext/hooks_runner.go | 2 +- cli/azd/pkg/ext/models.go | 2 +- cli/azd/pkg/ext/models_test.go | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index 7125762e964..b54a78ef225 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -304,7 +304,7 @@ func (h *HooksRunner) execLanguageHook( } // execShellHook runs a hook through the existing bash/powershell -// shell script pipeline. This preserves the original behaviour for +// shell script pipeline. This preserves the original behavior for // shell-based hooks. func (h *HooksRunner) execShellHook( ctx context.Context, diff --git a/cli/azd/pkg/ext/models.go b/cli/azd/pkg/ext/models.go index f0596cf7dbd..0a4de27f52d 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -191,7 +191,7 @@ func (hc *HookConfig) validate() error { } } - // --- existing shell behaviour (unchanged) --- + // --- existing shell behavior (unchanged) --- // If shell is not specified and it's an inline script, use OS default if hc.Shell == ScriptTypeUnknown && hc.path == "" { diff --git a/cli/azd/pkg/ext/models_test.go b/cli/azd/pkg/ext/models_test.go index 23177745639..523cdbdd8a0 100644 --- a/cli/azd/pkg/ext/models_test.go +++ b/cli/azd/pkg/ext/models_test.go @@ -69,8 +69,9 @@ func TestHookConfig_LanguageField(t *testing.T) { expectedDir: "", }, { - name: "AllFieldsTogether", - yamlInput: "run: src/hooks/predeploy.py\nshell: sh\nlanguage: python\ndir: src/hooks\ncontinueOnError: true\n", + 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", }, @@ -275,7 +276,7 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { filePath := filepath.Join(cwd, tt.createFile) err := os.MkdirAll(filepath.Dir(filePath), 0o755) require.NoError(t, err) - err = os.WriteFile(filePath, nil, 0o644) + err = os.WriteFile(filePath, nil, 0o600) require.NoError(t, err) } From 9289ccdbcd4fa6fd6c06189e60b41d7c43dcb558 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 2 Apr 2026 09:41:43 -0700 Subject: [PATCH 3/9] fix: align dependency file priority with framework services - Swap pyproject.toml before requirements.txt in project discovery, matching the convention in framework_service_python.go and internal/appdetect/python.go (PEP 621 preference) - Add PATH validation to resolvePythonCmd fallback - Add explanatory comments on project discovery design Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../pkg/tools/language/project_discovery.go | 14 ++++++++++--- .../tools/language/project_discovery_test.go | 20 +++++++++++++++++++ cli/azd/pkg/tools/language/python_executor.go | 13 ++++++++++-- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/cli/azd/pkg/tools/language/project_discovery.go b/cli/azd/pkg/tools/language/project_discovery.go index 0c0e51f5b6e..26ca1f4d748 100644 --- a/cli/azd/pkg/tools/language/project_discovery.go +++ b/cli/azd/pkg/tools/language/project_discovery.go @@ -32,11 +32,19 @@ type projectFileEntry struct { IsGlob bool // true for patterns like "*.*proj" } -// knownProjectFiles defines the project files to search for, in -// priority order. The first match found in a directory wins. +// 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: "requirements.txt", Language: ScriptLanguagePython}, {Name: "pyproject.toml", Language: ScriptLanguagePython}, + {Name: "requirements.txt", Language: ScriptLanguagePython}, {Name: "package.json", Language: ScriptLanguageJavaScript}, {Name: "*.*proj", Language: ScriptLanguageDotNet, IsGlob: true}, } diff --git a/cli/azd/pkg/tools/language/project_discovery_test.go b/cli/azd/pkg/tools/language/project_discovery_test.go index 9e55f22fc07..eddbf8a2a81 100644 --- a/cli/azd/pkg/tools/language/project_discovery_test.go +++ b/cli/azd/pkg/tools/language/project_discovery_test.go @@ -170,6 +170,26 @@ func TestDiscoverProjectFile_Priority(t *testing.T) { 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. // diff --git a/cli/azd/pkg/tools/language/python_executor.go b/cli/azd/pkg/tools/language/python_executor.go index 75483215f6b..6eb42f0fb72 100644 --- a/cli/azd/pkg/tools/language/python_executor.go +++ b/cli/azd/pkg/tools/language/python_executor.go @@ -256,11 +256,20 @@ func resolvePythonCmd( commandRunner exec.CommandRunner, ) string { if runtime.GOOS == "windows" { - if commandRunner.ToolInPath("py") == nil { - return "py" + // 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" } From b6eb8325375235718927358a3c960370c51d4e93 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 2 Apr 2026 13:20:08 -0700 Subject: [PATCH 4/9] fix: auto-infer dir from run path for language hooks When the dir field is not explicitly set on a language hook, it is now automatically inferred from the directory containing the script referenced by run. This eliminates redundant configuration: Before (both required): run: hooks/preprovision/main.py dir: hooks/preprovision After (dir auto-inferred): run: hooks/preprovision/main.py The dir field remains available as an optional override when the project root differs from the script's directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/docs/language-hooks.md | 44 ++++++++++++----- cli/azd/pkg/ext/hooks_runner.go | 9 +++- cli/azd/pkg/ext/models.go | 16 +++--- cli/azd/pkg/ext/models_test.go | 86 +++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 21 deletions(-) diff --git a/cli/azd/docs/language-hooks.md b/cli/azd/docs/language-hooks.md index 5145a8f96cb..d0e226ffdbd 100644 --- a/cli/azd/docs/language-hooks.md +++ b/cli/azd/docs/language-hooks.md @@ -31,20 +31,26 @@ When omitted, the language is **auto-detected** from the file extension of the ### `dir` (string, optional) -Specifies the working directory for language hook execution. This directory is -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. +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). -When omitted, defaults to the directory containing the script file. +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 dependencies are installed automatically if a `requirements.txt` -or `pyproject.toml` is found in the script's directory. +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: @@ -52,6 +58,18 @@ hooks: 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 @@ -64,18 +82,18 @@ hooks: language: python ``` -### Python hook with project directory +### Python hook with project directory override -When the script's dependencies are in a different directory (e.g. a -subdirectory with its own `requirements.txt`), use `dir` to point to the -project root: +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/main.py + run: ./hooks/data-tool/src/main.py language: python - dir: ./hooks/data-tool + dir: ./hooks/data-tool # override: project root differs from script location ``` ### Python hook with platform overrides diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index b54a78ef225..bccef9fe610 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -204,10 +204,15 @@ func (h *HooksRunner) execLanguageHook( boundaryDir = hookConfig.cwd } - // Determine working directory: explicit Dir → script dir → cwd. + // Determine working directory from Dir (set explicitly or + // auto-inferred from the run path by validate). cwd := h.cwd if hookConfig.Dir != "" { - cwd = hookConfig.Dir + dir := hookConfig.Dir + if !filepath.IsAbs(dir) { + dir = filepath.Join(boundaryDir, dir) + } + cwd = dir } else if hookConfig.path != "" { cwd = filepath.Dir( filepath.Join(boundaryDir, hookConfig.path), diff --git a/cli/azd/pkg/ext/models.go b/cli/azd/pkg/ext/models.go index 0a4de27f52d..322a7cd3005 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -101,12 +101,11 @@ type HookConfig struct { // extension is used. For inline scripts, Shell or Language must be // set explicitly. Language language.ScriptLanguage `yaml:"language,omitempty" json:"language,omitempty"` - // Dir specifies an optional working directory for language hook - // execution. It is used as the project root for dependency - // installation (e.g. pip install) and as the cwd when running the - // script. Relative paths are resolved from the project or service - // root. When empty, defaults to the directory containing the - // script file. + // 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"` @@ -176,6 +175,11 @@ func (hc *HookConfig) validate() error { // 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 } diff --git a/cli/azd/pkg/ext/models_test.go b/cli/azd/pkg/ext/models_test.go index 523cdbdd8a0..2fd591fc048 100644 --- a/cli/azd/pkg/ext/models_test.go +++ b/cli/azd/pkg/ext/models_test.go @@ -299,6 +299,92 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { } } +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 From cbe97a8e5a8b8bc335111d193c828072079d2602 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 2 Apr 2026 15:34:51 -0700 Subject: [PATCH 5/9] fix: resolve test failures for error mapping and service hooks - Add ErrUnsupportedLanguage and ErrShellLanguage to excludedErrors in errors_test.go (internal hook routing errors caught in hooks_runner.go) - Fix Test_ServiceHooks_Registered mock expectations for refactored hook execution pipeline Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/internal/cmd/errors_test.go | 4 ++++ cli/azd/pkg/ext/hooks_manager.go | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/cli/azd/internal/cmd/errors_test.go b/cli/azd/internal/cmd/errors_test.go index a14112341be..2cdc8c37ef1 100644 --- a/cli/azd/internal/cmd/errors_test.go +++ b/cli/azd/internal/cmd/errors_test.go @@ -1369,6 +1369,10 @@ func Test_PackageLevelErrorsMapped(t *testing.T) { // Extension SDK errors used by extensions, never reach host MapError "ErrProjectNotFound": "pkg/azdext: extension SDK helper, used by extensions not the host", + + // Internal hook routing errors — caught and handled in hooks_runner.go before reaching the user + "ErrUnsupportedLanguage": "pkg/tools/language: internal hook routing error, caught in hooks_runner.go", + "ErrShellLanguage": "pkg/tools/language: internal hook routing error, caught in hooks_runner.go", } // Find the azd root directory (two levels up from internal/cmd) diff --git a/cli/azd/pkg/ext/hooks_manager.go b/cli/azd/pkg/ext/hooks_manager.go index 4f7ae7bd12d..d0fdb2c94a1 100644 --- a/cli/azd/pkg/ext/hooks_manager.go +++ b/cli/azd/pkg/ext/hooks_manager.go @@ -292,6 +292,13 @@ func (h *HooksManager) validateLanguageRuntimes( 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 { From a41b0dc53aa114a39208dc32e8defd41c883a752 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 2 Apr 2026 17:05:56 -0700 Subject: [PATCH 6/9] refactor: unify shell and language hooks under single ScriptExecutor Replace the dual execution pipeline (execShellHook / execLanguageHook) with a single unified flow through tools.ScriptExecutor. All hook types - bash, PowerShell, and Python - now implement the same two-phase interface: Prepare() + Execute(). This eliminates branching in the hooks runner and makes adding new languages a single-file change. Key changes: - Move ScriptExecutor interface to pkg/tools/script.go (was in language/) - Bash: add no-op Prepare(), implement ScriptExecutor - PowerShell: move pwsh/powershell fallback from Execute to Prepare - Remove GetScript(), execShellHook(), execLanguageHook() - Single execHook() path for all hook types - Remove ErrShellLanguage (shells are now just another executor) - Remove UserPwsh from ExecOptions (resolved in PS constructor) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/internal/cmd/errors_test.go | 1 - cli/azd/pkg/ext/hooks_runner.go | 120 ++----- cli/azd/pkg/ext/hooks_runner_test.go | 297 ++++++------------ cli/azd/pkg/tools/bash/bash.go | 13 +- cli/azd/pkg/tools/bash/bash_test.go | 7 + cli/azd/pkg/tools/language/executor.go | 60 +--- cli/azd/pkg/tools/language/executor_test.go | 23 +- cli/azd/pkg/tools/language/python_executor.go | 5 - cli/azd/pkg/tools/powershell/powershell.go | 85 +++-- .../pkg/tools/powershell/powershell_test.go | 143 ++++----- cli/azd/pkg/tools/script.go | 16 +- 11 files changed, 273 insertions(+), 497 deletions(-) diff --git a/cli/azd/internal/cmd/errors_test.go b/cli/azd/internal/cmd/errors_test.go index 2cdc8c37ef1..68a35b00c3c 100644 --- a/cli/azd/internal/cmd/errors_test.go +++ b/cli/azd/internal/cmd/errors_test.go @@ -1372,7 +1372,6 @@ func Test_PackageLevelErrorsMapped(t *testing.T) { // Internal hook routing errors — caught and handled in hooks_runner.go before reaching the user "ErrUnsupportedLanguage": "pkg/tools/language: internal hook routing error, caught in hooks_runner.go", - "ErrShellLanguage": "pkg/tools/language: internal hook routing error, caught in hooks_runner.go", } // Find the azd root directory (two levels up from internal/cmd) diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index bccef9fe610..54e7c6b7751 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -20,9 +20,7 @@ import ( "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/language" - "github.com/azure/azure-dev/cli/azd/pkg/tools/powershell" "github.com/azure/azure-dev/cli/azd/pkg/tools/python" ) @@ -122,26 +120,6 @@ 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 { @@ -173,31 +151,11 @@ func (h *HooksRunner) execHook( } } - // validate() resolves the hook's language, path, and shell - // type. It must run before the language/shell branch below. + // validate() resolves the hook's language, path, and shell type. if err := hookConfig.validate(); err != nil { return err } - // Language hooks (Python, JS, TS, DotNet) are executed by a - // language-specific ScriptExecutor instead of a shell script. - if hookConfig.IsLanguageHook() { - return h.execLanguageHook( - ctx, hookConfig, hookEnv.Environ(), options, - ) - } - - return h.execShellHook(ctx, hookConfig, hookEnv.Environ(), options) -} - -// execLanguageHook prepares and executes a programming-language hook -// via the [language.ScriptExecutor] pipeline. -func (h *HooksRunner) execLanguageHook( - ctx context.Context, - hookConfig *HookConfig, - envVars []string, - options *tools.ExecOptions, -) error { // Determine the boundary directory for project file discovery. boundaryDir := h.cwd if hookConfig.cwd != "" { @@ -213,12 +171,15 @@ func (h *HooksRunner) execLanguageHook( dir = filepath.Join(boundaryDir, dir) } cwd = dir - } else if hookConfig.path != "" { + } else if hookConfig.path != "" && hookConfig.IsLanguageHook() { cwd = filepath.Dir( filepath.Join(boundaryDir, hookConfig.path), ) } + envVars := hookEnv.Environ() + + // Create executor (unified factory for ALL languages). pythonCli := python.NewCli(h.commandRunner) executor, err := language.GetExecutor( hookConfig.Language, @@ -232,8 +193,7 @@ func (h *HooksRunner) execLanguageHook( if errors.Is(err, language.ErrUnsupportedLanguage) { return &errorhandler.ErrorWithSuggestion{ Err: fmt.Errorf( - "getting %s executor for hook '%s': %w", - hookConfig.Language, + "getting executor for hook '%s': %w", hookConfig.Name, err, ), @@ -243,25 +203,28 @@ func (h *HooksRunner) execLanguageHook( hookConfig.Language, hookConfig.Name, ), - Suggestion: "Currently only Python hooks are " + - "supported. Use a shell script (sh/pwsh)" + - " or Python instead.", + Suggestion: "Currently only Python, Bash, and " + + "PowerShell hooks are supported.", } } return fmt.Errorf( - "getting %s executor for hook '%s': %w", - hookConfig.Language, hookConfig.Name, err, + "getting executor for hook '%s': %w", + hookConfig.Name, err, ) } + // 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 != "" { + 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 %s hook '%s' (%s)\n", - hookConfig.Language, hookConfig.Name, scriptPath, + "Preparing hook '%s' (%s)\n", + hookConfig.Name, hookConfig.Language, ) if err := executor.Prepare(ctx, scriptPath); err != nil { @@ -278,69 +241,34 @@ func (h *HooksRunner) execLanguageHook( hookConfig.Name, ), Suggestion: fmt.Sprintf( - "Ensure the %s runtime is installed "+ - "and the script at '%s' is valid. "+ - "Check dependency files "+ - "(requirements.txt / pyproject.toml) "+ - "for errors.", + "Ensure the required runtime for '%s' is installed.", hookConfig.Language, - scriptPath, ), } } + // Configure console/previewer. if h.configureExecOptions(ctx, hookConfig, options) { defer h.console.StopPreviewer(ctx, false) } + // Execute (unified). log.Printf( - "Executing %s hook '%s' (%s)\n", - hookConfig.Language, hookConfig.Name, scriptPath, + "Executing hook '%s' (%s)\n", + hookConfig.Name, scriptPath, ) res, err := executor.Execute(ctx, scriptPath, *options) - if err != nil { - return h.handleHookError( - ctx, hookConfig, res, scriptPath, err, - ) - } - - return nil -} - -// execShellHook runs a hook through the existing bash/powershell -// shell script pipeline. This preserves the original behavior for -// shell-based hooks. -func (h *HooksRunner) execShellHook( - ctx context.Context, - hookConfig *HookConfig, - envVars []string, - options *tools.ExecOptions, -) error { - script, err := h.GetScript(hookConfig, envVars) - if err != nil { - return err - } - - if h.configureExecOptions(ctx, hookConfig, options) { - 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) if err != nil { hookErr := h.handleHookError( - ctx, hookConfig, res, hookConfig.path, err, + ctx, hookConfig, res, scriptPath, err, ) 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) } diff --git a/cli/azd/pkg/ext/hooks_runner_test.go b/cli/azd/pkg/ext/hooks_runner_test.go index 72f115309e0..f1a35a897a4 100644 --- a/cli/azd/pkg/ext/hooks_runner_test.go +++ b/cli/azd/pkg/ext/hooks_runner_test.go @@ -8,7 +8,6 @@ import ( "fmt" "os" "path/filepath" - "reflect" "strings" "testing" @@ -262,7 +261,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) @@ -274,150 +276,126 @@ func Test_Hooks_GetScript(t *testing.T) { }, ) - hooksMap := map[string][]*HookConfig{ - "bash": { - { - Run: "scripts/script.sh", - }, - }, - "pwsh": { - { - Run: "scripts/script.ps1", - }, - }, - "inline": { - { - Shell: ShellTypeBash, - Run: "echo 'hello'", - }, - }, - "inlineWithUrl": { - { - Shell: ShellTypePowershell, - Run: "Invoke-WebRequest -Uri \"https://sample.com/sample.json\" -OutFile \"out.json\"", - }, - }, - } - - ensureScriptsExist(t, hooksMap) + // 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: "scripts/script.sh", + }}, + } - t.Run("Bash", func(t *testing.T) { - hookConfig := hooksMap["bash"][0] + shellRan := false mockContext := mocks.NewMockContext(context.Background()) + 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, + hooksManager, mockContext.CommandRunner, envManager, + mockContext.Console, cwd, hooksMap, env, 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, shellRan) }) - t.Run("Powershell", func(t *testing.T) { - hookConfig := hooksMap["pwsh"][0] + 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()) + 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, + hooksManager, mockContext.CommandRunner, envManager, + mockContext.Console, cwd, hooksMap, env, 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) }) - t.Run("Inline Script", func(t *testing.T) { - tempDir := t.TempDir() - ostest.Chdir(t, tempDir) + t.Run("InlineBashHookExecutes", func(t *testing.T) { + hooksMap := map[string][]*HookConfig{ + "preinline": {{ + Name: "preinline", + Shell: ShellTypeBash, + Run: "echo 'Hello'", + }}, + } - hookConfig := hooksMap["inline"][0] + inlineRan := false mockContext := mocks.NewMockContext(context.Background()) + 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, + hooksManager, mockContext.CommandRunner, envManager, + mockContext.Console, cwd, hooksMap, env, 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) - - fileInfo, err := os.Stat(hookConfig.path) - require.NotNil(t, fileInfo) + err := runner.RunHooks(*mockContext.Context, HookTypePre, nil, "inline") require.NoError(t, err) + require.True(t, inlineRan) }) - t.Run("Inline With Url", func(t *testing.T) { - tempDir := t.TempDir() - ostest.Chdir(t, tempDir) + t.Run("MissingRunReturnsError", func(t *testing.T) { + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Shell: ShellTypeBash, + }}, + } - hookConfig := hooksMap["inlineWithUrl"][0] mockContext := mocks.NewMockContext(context.Background()) hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( - hooksManager, - mockContext.CommandRunner, - envManager, - mockContext.Console, - cwd, - hooksMap, - env, - mockContext.Container, + hooksManager, mockContext.CommandRunner, envManager, + mockContext.Console, cwd, hooksMap, env, 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\"", - ) - 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) + err := runner.RunHooks(*mockContext.Context, HookTypePre, nil, "deploy") + require.Error(t, err) + require.ErrorIs(t, err, ErrRunRequired) }) - } // Test_ExecHook_LanguageHooks verifies the integration between @@ -746,100 +724,3 @@ func envSliceToMap(envVars []string) map[string]string { } return m } - -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) - - err := os.WriteFile("my-script.ps1", nil, osutil.PermissionFile) - require.NoError(t, err) - - env := environment.New("test") - envManager := &mockenv.MockEnvManager{} - - 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, - ) - - 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'", - }, - }, - } - - for _, test := range scriptValidations { - if test.createFile { - ensureScriptsExist( - t, - map[string][]*HookConfig{ - "test": {test.config}, - }, - ) - } - - 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) - } - }) - } -} diff --git a/cli/azd/pkg/tools/bash/bash.go b/cli/azd/pkg/tools/bash/bash.go index dcec5c98076..232a2f0080e 100644 --- a/cli/azd/pkg/tools/bash/bash.go +++ b/cli/azd/pkg/tools/bash/bash.go @@ -12,8 +12,8 @@ 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 { +// NewBashScript creates a new ScriptExecutor for bash scripts. +func NewBashScript(commandRunner exec.CommandRunner, cwd string, envVars []string) tools.ScriptExecutor { return &bashScript{ commandRunner: commandRunner, cwd: cwd, @@ -27,8 +27,13 @@ type bashScript struct { envVars []string } -// Executes the specified bash script -// When interactive is true will attach to stdin, stdout & stderr +// Prepare is a no-op for bash — bash is assumed available on all platforms. +func (bs *bashScript) Prepare(_ context.Context, _ string) error { + return nil +} + +// Execute runs 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) { var runArgs exec.RunArgs // Bash likes all path separators in POSIX format diff --git a/cli/azd/pkg/tools/bash/bash_test.go b/cli/azd/pkg/tools/bash/bash_test.go index e84346f4e3f..9a616173510 100644 --- a/cli/azd/pkg/tools/bash/bash_test.go +++ b/cli/azd/pkg/tools/bash/bash_test.go @@ -23,6 +23,13 @@ func Test_Bash_Execute(t *testing.T) { "b=banana", } + t.Run("Prepare", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + bashScript := NewBashScript(mockContext.CommandRunner, workingDir, env) + err := bashScript.Prepare(*mockContext.Context, scriptPath) + require.NoError(t, err) + }) + t.Run("Success", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) diff --git a/cli/azd/pkg/tools/language/executor.go b/cli/azd/pkg/tools/language/executor.go index 950a3eed548..ce8e2cc8d1f 100644 --- a/cli/azd/pkg/tools/language/executor.go +++ b/cli/azd/pkg/tools/language/executor.go @@ -4,14 +4,14 @@ package language import ( - "context" - "errors" "fmt" "path/filepath" "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/bash" + "github.com/azure/azure-dev/cli/azd/pkg/tools/powershell" "github.com/azure/azure-dev/cli/azd/pkg/tools/python" ) @@ -42,41 +42,13 @@ const ( ) // ErrUnsupportedLanguage is returned by [GetExecutor] when the -// requested [ScriptLanguage] is recognized but no [ScriptExecutor] +// requested [ScriptLanguage] is recognized but no executor // implementation exists yet (e.g. JavaScript, TypeScript, DotNet). -var ErrUnsupportedLanguage = errors.New( - "language is not yet supported; supported languages: python. " + +var ErrUnsupportedLanguage = fmt.Errorf( + "language is not yet supported; supported languages: python, sh, pwsh. " + "JavaScript, TypeScript, and .NET support is planned", ) -// ErrShellLanguage is returned by [GetExecutor] when the caller -// requests an executor for a shell language (Bash or PowerShell). -// Shell scripts are handled by the existing shell script runner in -// [pkg/ext] and do not use the [ScriptExecutor] pipeline. -var ErrShellLanguage = errors.New( - "shell languages (sh, pwsh) are handled by the existing " + - "shell script runner, not the language executor pipeline", -) - -// ScriptExecutor defines the interface for language-specific hook -// script preparation and execution. -type ScriptExecutor interface { - // Language returns the script language this executor handles. - Language() ScriptLanguage - - // Prepare performs pre-execution steps such as runtime - // validation, dependency installation, or build steps. - Prepare(ctx context.Context, scriptPath string) error - - // Execute runs the script at the given path and returns the - // result. The signature is compatible with [tools.Script]. - Execute( - ctx context.Context, - scriptPath string, - options tools.ExecOptions, - ) (exec.RunResult, error) -} - // InferLanguageFromPath determines the [ScriptLanguage] from the // file extension of the given path. Extension matching is // case-insensitive. The following extensions are recognized: @@ -110,12 +82,10 @@ func InferLanguageFromPath(path string) ScriptLanguage { } } -// GetExecutor returns a [ScriptExecutor] for the given language. +// GetExecutor returns a [tools.ScriptExecutor] for the given language. // -// Phase 1 supports only Python. JavaScript, TypeScript, and DotNet -// return [ErrUnsupportedLanguage]. Bash and PowerShell return -// [ErrShellLanguage] because they are handled by the existing shell -// script runner. +// All hook types — bash, PowerShell, and Python — are supported. +// JavaScript, TypeScript, and DotNet return [ErrUnsupportedLanguage]. // // The boundaryDir limits project file discovery during Prepare; cwd // sets the working directory for script execution; envVars are @@ -127,8 +97,16 @@ func GetExecutor( boundaryDir string, cwd string, envVars []string, -) (ScriptExecutor, error) { +) (tools.ScriptExecutor, error) { switch lang { + case ScriptLanguageBash: + return bash.NewBashScript( + commandRunner, cwd, envVars, + ), nil + case ScriptLanguagePowerShell: + return powershell.NewPowershellScript( + commandRunner, cwd, envVars, + ), nil case ScriptLanguagePython: return newPythonExecutor( commandRunner, pythonCli, @@ -140,10 +118,6 @@ func GetExecutor( return nil, fmt.Errorf( "%w: %s", ErrUnsupportedLanguage, lang, ) - case ScriptLanguageBash, ScriptLanguagePowerShell: - return nil, fmt.Errorf( - "%w: %s", ErrShellLanguage, lang, - ) default: return nil, fmt.Errorf( "unknown script language: %q", string(lang), diff --git a/cli/azd/pkg/tools/language/executor_test.go b/cli/azd/pkg/tools/language/executor_test.go index 3f84307d09d..9d6789343d4 100644 --- a/cli/azd/pkg/tools/language/executor_test.go +++ b/cli/azd/pkg/tools/language/executor_test.go @@ -116,6 +116,16 @@ func TestGetExecutor(t *testing.T) { language: ScriptLanguagePython, wantExec: true, }, + { + name: "BashReturnsExecutor", + language: ScriptLanguageBash, + wantExec: true, + }, + { + name: "PowerShellReturnsExecutor", + language: ScriptLanguagePowerShell, + wantExec: true, + }, { name: "JavaScriptUnsupported", language: ScriptLanguageJavaScript, @@ -131,16 +141,6 @@ func TestGetExecutor(t *testing.T) { language: ScriptLanguageDotNet, wantErr: ErrUnsupportedLanguage, }, - { - name: "BashShellLanguage", - language: ScriptLanguageBash, - wantErr: ErrShellLanguage, - }, - { - name: "PowerShellShellLanguage", - language: ScriptLanguagePowerShell, - wantErr: ErrShellLanguage, - }, { name: "UnknownReturnsError", language: ScriptLanguageUnknown, @@ -182,9 +182,6 @@ func TestGetExecutor(t *testing.T) { if tt.wantExec { require.NotNil(t, executor) - assert.Equal( - t, tt.language, executor.Language(), - ) } }) } diff --git a/cli/azd/pkg/tools/language/python_executor.go b/cli/azd/pkg/tools/language/python_executor.go index 6eb42f0fb72..d803bdc3fe1 100644 --- a/cli/azd/pkg/tools/language/python_executor.go +++ b/cli/azd/pkg/tools/language/python_executor.go @@ -74,11 +74,6 @@ func newPythonExecutor( } } -// Language returns [ScriptLanguagePython]. -func (e *pythonExecutor) Language() ScriptLanguage { - return ScriptLanguagePython -} - // 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 diff --git a/cli/azd/pkg/tools/powershell/powershell.go b/cli/azd/pkg/tools/powershell/powershell.go index 718b6c7ce7b..5169f58708f 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,12 +14,13 @@ 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 { +// NewPowershellScript creates a new ScriptExecutor for PowerShell scripts. +func NewPowershellScript(commandRunner exec.CommandRunner, cwd string, envVars []string) tools.ScriptExecutor { return &powershellScript{ commandRunner: commandRunner, cwd: cwd, envVars: envVars, + shellCmd: "pwsh", // default, resolved in Prepare } } @@ -28,39 +28,51 @@ type powershellScript 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 (ps *powershellScript) Prepare(_ context.Context, _ string) error { + // Try pwsh first. + if ps.commandRunner.ToolInPath("pwsh") == nil { + ps.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 ps.commandRunner.ToolInPath("powershell") == nil { + ps.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). + // 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", + )), + } +} + +// Execute runs the PowerShell script using the shell resolved in Prepare. +func (ps *powershellScript) Execute( + ctx context.Context, path string, options tools.ExecOptions, +) (exec.RunResult, error) { + runArgs := exec.NewRunArgs(ps.shellCmd, path). WithCwd(ps.cwd). WithEnv(ps.envVars). WithShell(true) @@ -73,18 +85,5 @@ func (ps *powershellScript) Execute(ctx context.Context, path string, options to runArgs = runArgs.WithStdOut(options.StdOut) } - 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")), - } - } - } - - return result, err + return ps.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..a1408d5e65b 100644 --- a/cli/azd/pkg/tools/powershell/powershell_test.go +++ b/cli/azd/pkg/tools/powershell/powershell_test.go @@ -8,77 +8,78 @@ 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", - } - - t.Run("Success", func(t *testing.T) { +func Test_Powershell_Prepare(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 := NewPowershellScript(mockContext.CommandRunner, "cwd", nil) + err := ps.Prepare(*mockContext.Context, "script.ps1") - 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 := NewPowershellScript(mockContext.CommandRunner, "cwd", nil) + err := ps.Prepare(*mockContext.Context, "script.ps1") - 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 := NewPowershellScript(mockContext.CommandRunner, "cwd", nil) + err := ps.Prepare(*mockContext.Context, "script.ps1") - 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 +87,13 @@ 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 := NewPowershellScript(mockContext.CommandRunner, workingDir, env) + require.NoError(t, ps.Prepare(*mockContext.Context, scriptPath)) - powershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) - runResult, err := powershellScript.Execute( + runResult, err := ps.Execute( *mockContext.Context, scriptPath, - tools.ExecOptions{UserPwsh: userPwsh, Interactive: new(true)}, + tools.ExecOptions{Interactive: new(true)}, ) require.NotNil(t, runResult) @@ -102,8 +102,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,31 +110,16 @@ 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 := NewPowershellScript(mockContext.CommandRunner, workingDir, env) + require.NoError(t, ps.Prepare(*mockContext.Context, scriptPath)) - 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( + runResult, err := ps.Execute( *mockContext.Context, scriptPath, - tools.ExecOptions{UserPwsh: "pwsh", Interactive: new(true)}, + tools.ExecOptions{Interactive: new(true)}, ) + require.Equal(t, 1, runResult.ExitCode) require.Error(t, err) }) @@ -144,15 +127,13 @@ func Test_Powershell_Execute(t *testing.T) { name string value tools.ExecOptions }{ - {name: "Interactive", value: tools.ExecOptions{UserPwsh: "pwsh", Interactive: new(true)}}, - {name: "NonInteractive", value: tools.ExecOptions{UserPwsh: "pwsh", Interactive: new(false)}}, + {name: "Interactive", value: tools.ExecOptions{Interactive: new(true)}}, + {name: "NonInteractive", value: tools.ExecOptions{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 +143,10 @@ 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 := NewPowershellScript(mockContext.CommandRunner, workingDir, env) + require.NoError(t, ps.Prepare(*mockContext.Context, scriptPath)) + + 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..39b385aea7d 100644 --- a/cli/azd/pkg/tools/script.go +++ b/cli/azd/pkg/tools/script.go @@ -10,14 +10,22 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/exec" ) -// ExecOptions provide configuration for how scripts are executed +// ExecOptions provide configuration for how scripts are executed. type ExecOptions struct { Interactive *bool StdOut io.Writer - UserPwsh string } -// Utility to easily execute a bash script across platforms -type Script interface { +// ScriptExecutor is the unified interface for all hook script execution. +// Every executor follows a two-phase lifecycle: +// 1. Prepare — validate prerequisites, resolve tools, install dependencies +// 2. Execute — run the script +type ScriptExecutor interface { + // Prepare performs pre-execution setup. For shell scripts this may + // validate tool availability; for language scripts this may create + // virtual environments and install dependencies. + Prepare(ctx context.Context, scriptPath string) error + + // Execute runs the script at the given path. Execute(ctx context.Context, scriptPath string, options ExecOptions) (exec.RunResult, error) } From 1137a69b8e384f6967031a18167f1fdf676f5167 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 3 Apr 2026 11:13:00 -0700 Subject: [PATCH 7/9] refactor: IoC-based HookExecutor with ExecutionContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual executor factory with IoC named resolution. The hooks runner no longer constructs language-specific dependencies — executors are registered in container.go and resolved at runtime. Renames: - ScriptExecutor → HookExecutor - ExecOptions → folded into ExecutionContext - ScriptContext concept → ExecutionContext Key changes: - Executor constructors take only IoC-injectable deps (CommandRunner, python.Cli) - Per-invocation data (Cwd, EnvVars, BoundaryDir, Interactive, StdOut) flows via ExecutionContext - Named transient registration in container.go by language - Hooks runner resolves via serviceLocator.ResolveNamed() - Remove GetExecutor() factory and language-specific imports from hooks_runner Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/container.go | 15 ++ cli/azd/cmd/hooks.go | 2 +- cli/azd/cmd/middleware/hooks_test.go | 35 +++++ cli/azd/internal/cmd/errors_test.go | 3 - cli/azd/pkg/ext/hooks_runner.go | 89 ++++++------ cli/azd/pkg/ext/hooks_runner_test.go | 39 ++++- cli/azd/pkg/ext/models.go | 4 +- cli/azd/pkg/ext/python_hooks_e2e_test.go | 16 +- cli/azd/pkg/tools/bash/bash.go | 34 ++--- cli/azd/pkg/tools/bash/bash_test.go | 41 ++++-- cli/azd/pkg/tools/language/executor.go | 64 +------- cli/azd/pkg/tools/language/executor_test.go | 126 ---------------- cli/azd/pkg/tools/language/python_executor.go | 57 ++++---- .../tools/language/python_executor_test.go | 137 ++++++++++++------ cli/azd/pkg/tools/powershell/powershell.go | 44 +++--- .../pkg/tools/powershell/powershell_test.go | 45 +++--- cli/azd/pkg/tools/script.go | 43 ++++-- 17 files changed, 394 insertions(+), 400 deletions(-) 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/middleware/hooks_test.go b/cli/azd/cmd/middleware/hooks_test.go index 524677bf5b2..79aa673df3b 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" @@ -492,6 +503,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 +552,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 +599,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 +678,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 +721,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 +778,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 +816,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 +863,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 +909,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/internal/cmd/errors_test.go b/cli/azd/internal/cmd/errors_test.go index 68a35b00c3c..a14112341be 100644 --- a/cli/azd/internal/cmd/errors_test.go +++ b/cli/azd/internal/cmd/errors_test.go @@ -1369,9 +1369,6 @@ func Test_PackageLevelErrorsMapped(t *testing.T) { // Extension SDK errors used by extensions, never reach host MapError "ErrProjectNotFound": "pkg/azdext: extension SDK helper, used by extensions not the host", - - // Internal hook routing errors — caught and handled in hooks_runner.go before reaching the user - "ErrUnsupportedLanguage": "pkg/tools/language: internal hook routing error, caught in hooks_runner.go", } // Find the azd root directory (two levels up from internal/cmd) diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index 54e7c6b7751..9d1283b0620 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -5,7 +5,6 @@ package ext import ( "context" - "errors" "fmt" "log" "os" @@ -20,8 +19,6 @@ import ( "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/language" - "github.com/azure/azure-dev/cli/azd/pkg/tools/python" ) // Hooks enable support to invoke integration scripts before & after commands @@ -94,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...) @@ -121,10 +118,10 @@ func (h *HooksRunner) RunHooks( } func (h *HooksRunner) execHook( - ctx context.Context, hookConfig *HookConfig, options *tools.ExecOptions, + 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()) @@ -179,38 +176,36 @@ func (h *HooksRunner) execHook( envVars := hookEnv.Environ() - // Create executor (unified factory for ALL languages). - pythonCli := python.NewCli(h.commandRunner) - executor, err := language.GetExecutor( - hookConfig.Language, - h.commandRunner, - pythonCli, - boundaryDir, - cwd, - envVars, - ) - if err != nil { - if errors.Is(err, language.ErrUnsupportedLanguage) { - return &errorhandler.ErrorWithSuggestion{ - Err: fmt.Errorf( - "getting executor for hook '%s': %w", - hookConfig.Name, - err, - ), - Message: fmt.Sprintf( - "The '%s' language is not yet supported "+ - "for hook '%s'.", - hookConfig.Language, - hookConfig.Name, - ), - Suggestion: "Currently only Python, Bash, and " + - "PowerShell hooks are supported.", - } + // Build execution context. + execCtx := tools.ExecutionContext{ + Cwd: cwd, + EnvVars: envVars, + BoundaryDir: boundaryDir, + } + + // 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.", } - return fmt.Errorf( - "getting executor for hook '%s': %w", - hookConfig.Name, err, - ) } // Resolve script path. Language hooks need the full path so @@ -227,7 +222,7 @@ func (h *HooksRunner) execHook( hookConfig.Name, hookConfig.Language, ) - if err := executor.Prepare(ctx, scriptPath); err != nil { + if err := executor.Prepare(ctx, scriptPath, execCtx); err != nil { return &errorhandler.ErrorWithSuggestion{ Err: fmt.Errorf( "preparing %s hook '%s': %w", @@ -248,7 +243,7 @@ func (h *HooksRunner) execHook( } // Configure console/previewer. - if h.configureExecOptions(ctx, hookConfig, options) { + if h.configureExecContext(ctx, hookConfig, &execCtx) { defer h.console.StopPreviewer(ctx, false) } @@ -258,7 +253,7 @@ func (h *HooksRunner) execHook( hookConfig.Name, scriptPath, ) - res, err := executor.Execute(ctx, scriptPath, *options) + res, err := executor.Execute(ctx, scriptPath, execCtx) if err != nil { hookErr := h.handleHookError( ctx, hookConfig, res, scriptPath, err, @@ -276,29 +271,29 @@ func (h *HooksRunner) execHook( return nil } -// configureExecOptions resolves interactive mode and sets up the +// 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) configureExecOptions( +func (h *HooksRunner) configureExecContext( ctx context.Context, hookConfig *HookConfig, - options *tools.ExecOptions, + execCtx *tools.ExecutionContext, ) bool { formatter := h.console.GetFormatter() consoleInteractive := (formatter == nil || formatter.Kind() == output.NoneFormat) scriptInteractive := consoleInteractive && hookConfig.Interactive - if options.Interactive == nil { - options.Interactive = &scriptInteractive + 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 !*options.Interactive && options.StdOut == nil { + if !*execCtx.Interactive && execCtx.StdOut == nil { previewer := h.console.ShowPreviewer( ctx, &input.ShowPreviewerOptions{ @@ -307,7 +302,7 @@ func (h *HooksRunner) configureExecOptions( MaxLineCount: 8, }, ) - options.StdOut = previewer + execCtx.StdOut = previewer return true } diff --git a/cli/azd/pkg/ext/hooks_runner_test.go b/cli/azd/pkg/ext/hooks_runner_test.go index f1a35a897a4..99e26191883 100644 --- a/cli/azd/pkg/ext/hooks_runner_test.go +++ b/cli/azd/pkg/ext/hooks_runner_test.go @@ -14,6 +14,10 @@ import ( "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" @@ -21,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) @@ -70,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) { @@ -105,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) { @@ -140,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) { @@ -175,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) { @@ -209,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) { @@ -299,6 +327,7 @@ func Test_Hooks_Validation(t *testing.T) { 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) { @@ -327,6 +356,7 @@ func Test_Hooks_Validation(t *testing.T) { shellRan := false mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) mockContext.CommandRunner.MockToolInPath("pwsh", nil) mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { @@ -359,6 +389,7 @@ func Test_Hooks_Validation(t *testing.T) { 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) { @@ -386,6 +417,7 @@ func Test_Hooks_Validation(t *testing.T) { } mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, mockContext.CommandRunner, envManager, @@ -399,7 +431,7 @@ func Test_Hooks_Validation(t *testing.T) { } // Test_ExecHook_LanguageHooks verifies the integration between -// [HooksRunner] and [language.ScriptExecutor] for non-shell hooks. +// [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() @@ -431,6 +463,7 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { executeRan := false mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) // Mock the Python version check issued by python.Cli.CheckInstalled // via tools.ExecuteCommand → commandRunner.Run. @@ -493,6 +526,7 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { 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) { @@ -548,6 +582,7 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { envManager.On("Reload", mock.Anything, env).Return(nil) mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) // Simulate Python not being installed — version check // fails with an error. @@ -603,6 +638,7 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { envManager.On("Reload", mock.Anything, env).Return(nil) mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) // Prepare succeeds (version check passes). mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { @@ -670,6 +706,7 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { var capturedEnv []string mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) // Allow version check to pass. mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { diff --git a/cli/azd/pkg/ext/models.go b/cli/azd/pkg/ext/models.go index 322a7cd3005..c2123a2b72f 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -74,7 +74,7 @@ type InvokeFn func() error // 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 [language.ScriptExecutor] pipeline. +// via the [tools.HookExecutor] pipeline. type HookConfig struct { // The location of the script hook (file path or inline) location ScriptLocation @@ -273,7 +273,7 @@ func (hc *HookConfig) IsUsingDefaultShell() bool { // 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 -// [language.ScriptExecutor] pipeline instead of the shell runner. +// [tools.HookExecutor] pipeline instead of the shell runner. func (hc *HookConfig) IsLanguageHook() bool { switch hc.Language { case language.ScriptLanguagePython, diff --git a/cli/azd/pkg/ext/python_hooks_e2e_test.go b/cli/azd/pkg/ext/python_hooks_e2e_test.go index 7efeefacc2e..aca1c183ddb 100644 --- a/cli/azd/pkg/ext/python_hooks_e2e_test.go +++ b/cli/azd/pkg/ext/python_hooks_e2e_test.go @@ -112,7 +112,7 @@ func stubPythonVersionCheck( // TestPythonHook_AutoDetectFromExtension verifies that a hook with // run: script.py (no explicit language:) auto-detects Python and -// routes through the ScriptExecutor pipeline. +// routes through the HookExecutor pipeline. func TestPythonHook_AutoDetectFromExtension(t *testing.T) { scriptRel := filepath.Join("hooks", "predeploy.py") cwd := newPythonTestFixture(t, scriptRel, false) @@ -130,6 +130,7 @@ func TestPythonHook_AutoDetectFromExtension(t *testing.T) { } mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) stubPythonVersionCheck(mockCtx) var executedScript string @@ -185,6 +186,7 @@ func TestPythonHook_ExplicitLanguage(t *testing.T) { } mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) stubPythonVersionCheck(mockCtx) executed := false @@ -233,6 +235,7 @@ func TestPythonHook_EnvVarsPassthrough(t *testing.T) { } mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) stubPythonVersionCheck(mockCtx) var capturedEnv []string @@ -282,6 +285,7 @@ func TestPythonHook_WithRequirementsTxt(t *testing.T) { } mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) stubPythonVersionCheck(mockCtx) callLog := []string{} @@ -365,6 +369,7 @@ func TestPythonHook_StdoutCapture(t *testing.T) { } mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) stubPythonVersionCheck(mockCtx) mockCtx.CommandRunner.When(func( @@ -411,6 +416,7 @@ func TestPythonHook_NonZeroExitCode(t *testing.T) { } mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) stubPythonVersionCheck(mockCtx) mockCtx.CommandRunner.When(func( @@ -456,6 +462,7 @@ func TestPythonHook_ContinueOnError(t *testing.T) { } mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) stubPythonVersionCheck(mockCtx) mockCtx.CommandRunner.When(func( @@ -499,6 +506,7 @@ func TestPythonHook_ProjectLevel(t *testing.T) { } mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) stubPythonVersionCheck(mockCtx) executed := false @@ -549,6 +557,7 @@ func TestPythonHook_ServiceLevel(t *testing.T) { } mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) stubPythonVersionCheck(mockCtx) var capturedCwd string @@ -619,6 +628,7 @@ func TestPythonHook_ShellHookUnaffected(t *testing.T) { } mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) stubPythonVersionCheck(mockCtx) shellRan := false @@ -753,6 +763,7 @@ func TestPythonHook_ExecutionPipeline(t *testing.T) { mockCtx := mocks.NewMockContext( context.Background(), ) + registerHookExecutors(mockCtx) stubPythonVersionCheck(mockCtx) // Derive the script base name for matching. @@ -811,6 +822,7 @@ func TestPythonHook_PythonBinaryResolution(t *testing.T) { } mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) stubPythonVersionCheck(mockCtx) var capturedCmd string @@ -884,6 +896,7 @@ func TestPythonHook_ExplicitDirOverridesCwd(t *testing.T) { } mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) stubPythonVersionCheck(mockCtx) var capturedCwd string @@ -932,6 +945,7 @@ func TestPythonHook_InlineScriptRejected(t *testing.T) { } mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) runner := buildRunner( t, mockCtx, cwd, hooksMap, env, diff --git a/cli/azd/pkg/tools/bash/bash.go b/cli/azd/pkg/tools/bash/bash.go index 232a2f0080e..0bb7f582f30 100644 --- a/cli/azd/pkg/tools/bash/bash.go +++ b/cli/azd/pkg/tools/bash/bash.go @@ -12,29 +12,25 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/tools" ) -// NewBashScript creates a new ScriptExecutor for bash scripts. -func NewBashScript(commandRunner exec.CommandRunner, cwd string, envVars []string) tools.ScriptExecutor { - 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 } // Prepare is a no-op for bash — bash is assumed available on all platforms. -func (bs *bashScript) Prepare(_ context.Context, _ string) error { +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 (bs *bashScript) Execute(ctx context.Context, path string, options tools.ExecOptions) (exec.RunResult, error) { +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, "\\", "/") @@ -46,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 9a616173510..ffa9b25acd9 100644 --- a/cli/azd/pkg/tools/bash/bash_test.go +++ b/cli/azd/pkg/tools/bash/bash_test.go @@ -25,8 +25,9 @@ func Test_Bash_Execute(t *testing.T) { t.Run("Prepare", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) - bashScript := NewBashScript(mockContext.CommandRunner, workingDir, env) - err := bashScript.Prepare(*mockContext.Context, scriptPath) + executor := NewExecutor(mockContext.CommandRunner) + execCtx := tools.ExecutionContext{Cwd: workingDir, EnvVars: env} + err := executor.Prepare(*mockContext.Context, scriptPath, execCtx) require.NoError(t, err) }) @@ -49,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) @@ -69,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) @@ -82,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 { @@ -99,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 index ce8e2cc8d1f..46faa2070e4 100644 --- a/cli/azd/pkg/tools/language/executor.go +++ b/cli/azd/pkg/tools/language/executor.go @@ -4,15 +4,8 @@ package language import ( - "fmt" "path/filepath" "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/bash" - "github.com/azure/azure-dev/cli/azd/pkg/tools/powershell" - "github.com/azure/azure-dev/cli/azd/pkg/tools/python" ) // ScriptLanguage identifies the programming language of a hook script. @@ -29,26 +22,18 @@ const ( // ScriptLanguagePowerShell identifies PowerShell scripts (.ps1 files). ScriptLanguagePowerShell ScriptLanguage = "pwsh" // ScriptLanguageJavaScript identifies JavaScript scripts (.js files). - // Not yet supported — returns [ErrUnsupportedLanguage] from [GetExecutor]. + // Not yet supported — IoC resolution will fail with a descriptive error. ScriptLanguageJavaScript ScriptLanguage = "js" // ScriptLanguageTypeScript identifies TypeScript scripts (.ts files). - // Not yet supported — returns [ErrUnsupportedLanguage] from [GetExecutor]. + // 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 — returns [ErrUnsupportedLanguage] from [GetExecutor]. + // Not yet supported — IoC resolution will fail with a descriptive error. ScriptLanguageDotNet ScriptLanguage = "dotnet" ) -// ErrUnsupportedLanguage is returned by [GetExecutor] when the -// requested [ScriptLanguage] is recognized but no executor -// implementation exists yet (e.g. JavaScript, TypeScript, DotNet). -var ErrUnsupportedLanguage = fmt.Errorf( - "language is not yet supported; supported languages: python, sh, pwsh. " + - "JavaScript, TypeScript, and .NET support is planned", -) - // InferLanguageFromPath determines the [ScriptLanguage] from the // file extension of the given path. Extension matching is // case-insensitive. The following extensions are recognized: @@ -81,46 +66,3 @@ func InferLanguageFromPath(path string) ScriptLanguage { return ScriptLanguageUnknown } } - -// GetExecutor returns a [tools.ScriptExecutor] for the given language. -// -// All hook types — bash, PowerShell, and Python — are supported. -// JavaScript, TypeScript, and DotNet return [ErrUnsupportedLanguage]. -// -// The boundaryDir limits project file discovery during Prepare; cwd -// sets the working directory for script execution; envVars are -// forwarded to all child processes. -func GetExecutor( - lang ScriptLanguage, - commandRunner exec.CommandRunner, - pythonCli *python.Cli, - boundaryDir string, - cwd string, - envVars []string, -) (tools.ScriptExecutor, error) { - switch lang { - case ScriptLanguageBash: - return bash.NewBashScript( - commandRunner, cwd, envVars, - ), nil - case ScriptLanguagePowerShell: - return powershell.NewPowershellScript( - commandRunner, cwd, envVars, - ), nil - case ScriptLanguagePython: - return newPythonExecutor( - commandRunner, pythonCli, - boundaryDir, cwd, envVars, - ), nil - case ScriptLanguageJavaScript, - ScriptLanguageTypeScript, - ScriptLanguageDotNet: - return nil, fmt.Errorf( - "%w: %s", ErrUnsupportedLanguage, lang, - ) - default: - return nil, fmt.Errorf( - "unknown script language: %q", string(lang), - ) - } -} diff --git a/cli/azd/pkg/tools/language/executor_test.go b/cli/azd/pkg/tools/language/executor_test.go index 9d6789343d4..21a09b49f18 100644 --- a/cli/azd/pkg/tools/language/executor_test.go +++ b/cli/azd/pkg/tools/language/executor_test.go @@ -4,14 +4,9 @@ package language import ( - "context" - "errors" "testing" - "github.com/azure/azure-dev/cli/azd/pkg/exec" - "github.com/azure/azure-dev/cli/azd/pkg/tools/python" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestInferLanguageFromPath(t *testing.T) { @@ -99,124 +94,3 @@ func TestInferLanguageFromPath(t *testing.T) { }) } } - -func TestGetExecutor(t *testing.T) { - mockRunner := &mockCommandRunner{} - pythonCli := python.NewCli(mockRunner) - - tests := []struct { - name string - language ScriptLanguage - wantErr error // sentinel error (checked via errors.Is) - wantErrMsg string // substring in error message - wantExec bool // true when a valid executor is expected - }{ - { - name: "PythonReturnsExecutor", - language: ScriptLanguagePython, - wantExec: true, - }, - { - name: "BashReturnsExecutor", - language: ScriptLanguageBash, - wantExec: true, - }, - { - name: "PowerShellReturnsExecutor", - language: ScriptLanguagePowerShell, - wantExec: true, - }, - { - name: "JavaScriptUnsupported", - language: ScriptLanguageJavaScript, - wantErr: ErrUnsupportedLanguage, - }, - { - name: "TypeScriptUnsupported", - language: ScriptLanguageTypeScript, - wantErr: ErrUnsupportedLanguage, - }, - { - name: "DotNetUnsupported", - language: ScriptLanguageDotNet, - wantErr: ErrUnsupportedLanguage, - }, - { - name: "UnknownReturnsError", - language: ScriptLanguageUnknown, - wantErrMsg: "unknown script language", - }, - { - name: "ArbitraryStringReturnsError", - language: ScriptLanguage("ruby"), - wantErrMsg: "unknown script language", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - executor, err := GetExecutor( - tt.language, mockRunner, pythonCli, - "", "", nil, - ) - - switch { - case tt.wantErr != nil: - require.Error(t, err) - assert.True( - t, - errors.Is(err, tt.wantErr), - "expected error %v, got %v", - tt.wantErr, err, - ) - assert.Nil(t, executor) - case tt.wantErrMsg != "": - require.Error(t, err) - assert.Contains( - t, err.Error(), tt.wantErrMsg, - ) - assert.Nil(t, executor) - default: - require.NoError(t, err) - } - - if tt.wantExec { - require.NotNil(t, executor) - } - }) - } -} - -// mockCommandRunner is a minimal mock of [exec.CommandRunner] -// used to construct test dependencies without invoking real -// processes. Optional function fields allow tests to customize -// behavior when the zero-value defaults are insufficient. -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 -} diff --git a/cli/azd/pkg/tools/language/python_executor.go b/cli/azd/pkg/tools/language/python_executor.go index d803bdc3fe1..412cfc361a7 100644 --- a/cli/azd/pkg/tools/language/python_executor.go +++ b/cli/azd/pkg/tools/language/python_executor.go @@ -14,6 +14,7 @@ import ( "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 @@ -38,39 +39,36 @@ type pythonTools interface { ) error } -// pythonExecutor implements [ScriptExecutor] for Python scripts. +// 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 - boundaryDir string // project/service root for discovery - cwd string // working directory for execution - envVars []string // environment variables for execution // venvPath is set by Prepare when a project context with a // dependency file is discovered. Empty means system Python. venvPath string } -// newPythonExecutor creates a pythonExecutor configured for the -// given execution context. The boundaryDir limits project file -// discovery; cwd sets the working directory for script execution; -// envVars are forwarded to all child processes. -func newPythonExecutor( +// 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, - boundaryDir string, - cwd string, - envVars []string, ) *pythonExecutor { return &pythonExecutor{ commandRunner: commandRunner, pythonCli: pythonCli, - boundaryDir: boundaryDir, - cwd: cwd, - envVars: envVars, } } @@ -81,6 +79,7 @@ func newPythonExecutor( 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 { @@ -93,7 +92,7 @@ func (e *pythonExecutor) Prepare( // 2. Discover project context for dependency installation. projCtx, err := DiscoverProjectFile( - scriptPath, e.boundaryDir, + scriptPath, execCtx.BoundaryDir, ) if err != nil { return fmt.Errorf( @@ -111,7 +110,7 @@ func (e *pythonExecutor) Prepare( venvPath := filepath.Join(projCtx.ProjectDir, venvName) if err := e.ensureVenv( - ctx, projCtx.ProjectDir, venvName, venvPath, + ctx, projCtx.ProjectDir, venvName, venvPath, execCtx.EnvVars, ); err != nil { return err } @@ -119,7 +118,7 @@ func (e *pythonExecutor) Prepare( // 4. Install dependencies from the discovered file. depFile := filepath.Base(projCtx.DependencyFile) if err := e.installDeps( - ctx, projCtx.ProjectDir, venvName, depFile, + ctx, projCtx.ProjectDir, venvName, depFile, execCtx.EnvVars, ); err != nil { return err } @@ -135,6 +134,7 @@ func (e *pythonExecutor) Prepare( func (e *pythonExecutor) ensureVenv( ctx context.Context, projectDir, venvName, venvPath string, + envVars []string, ) error { _, statErr := os.Stat(venvPath) if statErr == nil { @@ -151,7 +151,7 @@ func (e *pythonExecutor) ensureVenv( } if err := e.pythonCli.CreateVirtualEnv( - ctx, projectDir, venvName, e.envVars, + ctx, projectDir, venvName, envVars, ); err != nil { return fmt.Errorf( "creating python virtual environment at %q failed. "+ @@ -167,11 +167,12 @@ func (e *pythonExecutor) ensureVenv( 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, e.envVars, + ctx, projectDir, venvName, depFile, envVars, ); err != nil { return fmt.Errorf( "installing python requirements from %s. "+ @@ -181,7 +182,7 @@ func (e *pythonExecutor) installDeps( } case "pyproject.toml": if err := e.pythonCli.InstallProject( - ctx, projectDir, venvName, e.envVars, + ctx, projectDir, venvName, envVars, ); err != nil { return fmt.Errorf( "installing python project from pyproject.toml. "+ @@ -200,28 +201,28 @@ func (e *pythonExecutor) installDeps( func (e *pythonExecutor) Execute( ctx context.Context, scriptPath string, - options tools.ExecOptions, + execCtx tools.ExecutionContext, ) (exec.RunResult, error) { pyCmd := e.resolvePythonPath() runArgs := exec. NewRunArgs(pyCmd, scriptPath). - WithEnv(e.envVars) + WithEnv(execCtx.EnvVars) // Prefer configured cwd; fall back to script's directory. - cwd := e.cwd + cwd := execCtx.Cwd if cwd == "" { cwd = filepath.Dir(scriptPath) } runArgs = runArgs.WithCwd(cwd) - if options.Interactive != nil { + if execCtx.Interactive != nil { runArgs = runArgs.WithInteractive( - *options.Interactive, + *execCtx.Interactive, ) } - if options.StdOut != nil { - runArgs = runArgs.WithStdOut(options.StdOut) + if execCtx.StdOut != nil { + runArgs = runArgs.WithStdOut(execCtx.StdOut) } return e.commandRunner.Run(ctx, runArgs) diff --git a/cli/azd/pkg/tools/language/python_executor_test.go b/cli/azd/pkg/tools/language/python_executor_test.go index a5445b08809..1cda4fd0ced 100644 --- a/cli/azd/pkg/tools/language/python_executor_test.go +++ b/cli/azd/pkg/tools/language/python_executor_test.go @@ -11,6 +11,7 @@ import ( "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" @@ -79,6 +80,39 @@ func (m *mockPythonTools) InstallProject( 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 // --------------------------------------------------------------------------- @@ -87,11 +121,12 @@ func TestPythonPrepare_PythonNotInstalled(t *testing.T) { cli := &mockPythonTools{ checkInstalledErr: errors.New("python not found"), } - e := newPythonExecutor( - &mockCommandRunner{}, cli, t.TempDir(), "", nil, + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, ) - err := e.Prepare(t.Context(), "/any/hook.py") + 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") @@ -103,12 +138,16 @@ func TestPythonPrepare_PythonNotInstalled(t *testing.T) { func TestPythonPrepare_NoProjectFile(t *testing.T) { dir := t.TempDir() cli := &mockPythonTools{} - e := newPythonExecutor( - &mockCommandRunner{}, cli, dir, dir, nil, + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, ) + execCtx := tools.ExecutionContext{ + BoundaryDir: dir, + Cwd: dir, + } scriptPath := filepath.Join(dir, "hook.py") - err := e.Prepare(t.Context(), scriptPath) + err := e.Prepare(t.Context(), scriptPath, execCtx) require.NoError(t, err) assert.False(t, cli.createVenvCalled) @@ -129,12 +168,13 @@ func TestPythonPrepare_WithRequirementsTxt(t *testing.T) { ) cli := &mockPythonTools{} - e := newPythonExecutor( - &mockCommandRunner{}, cli, root, "", nil, + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, ) + execCtx := tools.ExecutionContext{BoundaryDir: root} scriptPath := filepath.Join(hooksDir, "deploy.py") - err := e.Prepare(t.Context(), scriptPath) + err := e.Prepare(t.Context(), scriptPath, execCtx) require.NoError(t, err) @@ -168,12 +208,13 @@ func TestPythonPrepare_WithPyprojectToml(t *testing.T) { ) cli := &mockPythonTools{} - e := newPythonExecutor( - &mockCommandRunner{}, cli, root, "", nil, + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, ) + execCtx := tools.ExecutionContext{BoundaryDir: root} scriptPath := filepath.Join(projectDir, "deploy.py") - err := e.Prepare(t.Context(), scriptPath) + err := e.Prepare(t.Context(), scriptPath, execCtx) require.NoError(t, err) @@ -202,12 +243,13 @@ func TestPythonPrepare_VenvAlreadyExists(t *testing.T) { require.NoError(t, os.MkdirAll(venvDir, 0o700)) cli := &mockPythonTools{} - e := newPythonExecutor( - &mockCommandRunner{}, cli, root, "", nil, + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, ) + execCtx := tools.ExecutionContext{BoundaryDir: root} scriptPath := filepath.Join(projectDir, "deploy.py") - err := e.Prepare(t.Context(), scriptPath) + err := e.Prepare(t.Context(), scriptPath, execCtx) require.NoError(t, err) assert.False( @@ -238,15 +280,18 @@ func TestPythonExecute_WithVenv(t *testing.T) { cli := &mockPythonTools{} runner := &mockCommandRunner{} - e := newPythonExecutor( - runner, cli, root, projectDir, nil, - ) + 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)) + require.NoError(t, e.Prepare(t.Context(), scriptPath, execCtx)) _, err := e.Execute( - t.Context(), scriptPath, tools.ExecOptions{}, + t.Context(), scriptPath, execCtx, ) require.NoError(t, err) @@ -274,13 +319,12 @@ func TestPythonExecute_WithVenv(t *testing.T) { func TestPythonExecute_WithoutVenv(t *testing.T) { dir := t.TempDir() runner := &mockCommandRunner{} - e := newPythonExecutor( - runner, &mockPythonTools{}, dir, "", nil, - ) + e := newPythonExecutorInternal(runner, &mockPythonTools{}) + execCtx := tools.ExecutionContext{BoundaryDir: dir} scriptPath := filepath.Join(dir, "hook.py") _, err := e.Execute( - t.Context(), scriptPath, tools.ExecOptions{}, + t.Context(), scriptPath, execCtx, ) require.NoError(t, err) @@ -296,14 +340,15 @@ func TestPythonExecute_WithoutVenv(t *testing.T) { func TestPythonExecute_EnvVarsPassthrough(t *testing.T) { runner := &mockCommandRunner{} envVars := []string{"FOO=bar", "BAZ=qux"} - e := newPythonExecutor( - runner, &mockPythonTools{}, - t.TempDir(), "", envVars, - ) + 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, tools.ExecOptions{}, + t.Context(), scriptPath, execCtx, ) require.NoError(t, err) @@ -312,15 +357,15 @@ func TestPythonExecute_EnvVarsPassthrough(t *testing.T) { func TestPythonExecute_InteractiveMode(t *testing.T) { runner := &mockCommandRunner{} - e := newPythonExecutor( - runner, &mockPythonTools{}, - t.TempDir(), "", nil, - ) + 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, - tools.ExecOptions{Interactive: new(true)}, + t.Context(), scriptPath, execCtx, ) require.NoError(t, err) @@ -333,14 +378,15 @@ func TestPythonExecute_WorkingDirectory(t *testing.T) { require.NoError(t, os.MkdirAll(customCwd, 0o700)) runner := &mockCommandRunner{} - e := newPythonExecutor( - runner, &mockPythonTools{}, - t.TempDir(), customCwd, nil, - ) + 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, tools.ExecOptions{}, + t.Context(), scriptPath, execCtx, ) require.NoError(t, err) @@ -349,15 +395,16 @@ func TestPythonExecute_WorkingDirectory(t *testing.T) { t.Run("FallbackToScriptDir", func(t *testing.T) { runner := &mockCommandRunner{} - e := newPythonExecutor( - runner, &mockPythonTools{}, - t.TempDir(), "", nil, // empty cwd - ) + 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, tools.ExecOptions{}, + t.Context(), scriptPath, execCtx, ) require.NoError(t, err) diff --git a/cli/azd/pkg/tools/powershell/powershell.go b/cli/azd/pkg/tools/powershell/powershell.go index 5169f58708f..598dfb09b84 100644 --- a/cli/azd/pkg/tools/powershell/powershell.go +++ b/cli/azd/pkg/tools/powershell/powershell.go @@ -14,37 +14,35 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/tools" ) -// NewPowershellScript creates a new ScriptExecutor for PowerShell scripts. -func NewPowershellScript(commandRunner exec.CommandRunner, cwd string, envVars []string) tools.ScriptExecutor { - 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" } // 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 (ps *powershellScript) Prepare(_ context.Context, _ string) error { +func (p *powershellExecutor) Prepare( + _ context.Context, _ string, _ tools.ExecutionContext, +) error { // Try pwsh first. - if ps.commandRunner.ToolInPath("pwsh") == nil { - ps.shellCmd = "pwsh" + if p.commandRunner.ToolInPath("pwsh") == nil { + p.shellCmd = "pwsh" return nil } // On Windows, fall back to powershell (PS5). if runtime.GOOS == "windows" { - if ps.commandRunner.ToolInPath("powershell") == nil { - ps.shellCmd = "powershell" + if p.commandRunner.ToolInPath("powershell") == nil { + p.shellCmd = "powershell" return nil } return &internal.ErrorWithSuggestion{ @@ -69,21 +67,21 @@ func (ps *powershellScript) Prepare(_ context.Context, _ string) error { } // Execute runs the PowerShell script using the shell resolved in Prepare. -func (ps *powershellScript) Execute( - ctx context.Context, path string, options tools.ExecOptions, +func (p *powershellExecutor) Execute( + ctx context.Context, path string, execCtx tools.ExecutionContext, ) (exec.RunResult, error) { - runArgs := exec.NewRunArgs(ps.shellCmd, path). - WithCwd(ps.cwd). - WithEnv(ps.envVars). + runArgs := exec.NewRunArgs(p.shellCmd, path). + 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 ps.commandRunner.Run(ctx, runArgs) + 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 a1408d5e65b..af0f14c32ee 100644 --- a/cli/azd/pkg/tools/powershell/powershell_test.go +++ b/cli/azd/pkg/tools/powershell/powershell_test.go @@ -18,12 +18,14 @@ import ( ) func Test_Powershell_Prepare(t *testing.T) { + emptyCtx := tools.ExecutionContext{} + t.Run("PwshAvailable", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) mockContext.CommandRunner.MockToolInPath("pwsh", nil) - ps := NewPowershellScript(mockContext.CommandRunner, "cwd", nil) - err := ps.Prepare(*mockContext.Context, "script.ps1") + ps := NewExecutor(mockContext.CommandRunner) + err := ps.Prepare(*mockContext.Context, "script.ps1", emptyCtx) require.NoError(t, err) }) @@ -39,8 +41,8 @@ func Test_Powershell_Prepare(t *testing.T) { ) mockContext.CommandRunner.MockToolInPath("powershell", nil) - ps := NewPowershellScript(mockContext.CommandRunner, "cwd", nil) - err := ps.Prepare(*mockContext.Context, "script.ps1") + ps := NewExecutor(mockContext.CommandRunner) + err := ps.Prepare(*mockContext.Context, "script.ps1", emptyCtx) require.NoError(t, err) }) @@ -54,8 +56,8 @@ func Test_Powershell_Prepare(t *testing.T) { "powershell", errors.New("powershell: command not found"), ) - ps := NewPowershellScript(mockContext.CommandRunner, "cwd", nil) - err := ps.Prepare(*mockContext.Context, "script.ps1") + ps := NewExecutor(mockContext.CommandRunner) + err := ps.Prepare(*mockContext.Context, "script.ps1", emptyCtx) require.Error(t, err) if sugErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err); ok { @@ -87,13 +89,15 @@ func Test_Powershell_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - ps := NewPowershellScript(mockContext.CommandRunner, workingDir, env) - require.NoError(t, ps.Prepare(*mockContext.Context, scriptPath)) + ps := NewExecutor(mockContext.CommandRunner) + execCtx := tools.ExecutionContext{Cwd: workingDir, EnvVars: env} + require.NoError(t, ps.Prepare(*mockContext.Context, scriptPath, execCtx)) + execCtx.Interactive = new(true) runResult, err := ps.Execute( *mockContext.Context, scriptPath, - tools.ExecOptions{Interactive: new(true)}, + execCtx, ) require.NotNil(t, runResult) @@ -110,13 +114,15 @@ func Test_Powershell_Execute(t *testing.T) { return exec.NewRunResult(1, "", "error message"), errors.New("error message") }) - ps := NewPowershellScript(mockContext.CommandRunner, workingDir, env) - require.NoError(t, ps.Prepare(*mockContext.Context, scriptPath)) + ps := NewExecutor(mockContext.CommandRunner) + execCtx := tools.ExecutionContext{Cwd: workingDir, EnvVars: env} + require.NoError(t, ps.Prepare(*mockContext.Context, scriptPath, execCtx)) + execCtx.Interactive = new(true) runResult, err := ps.Execute( *mockContext.Context, scriptPath, - tools.ExecOptions{Interactive: new(true)}, + execCtx, ) require.Equal(t, 1, runResult.ExitCode) @@ -125,10 +131,14 @@ func Test_Powershell_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 { @@ -143,8 +153,9 @@ func Test_Powershell_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - ps := NewPowershellScript(mockContext.CommandRunner, workingDir, env) - require.NoError(t, ps.Prepare(*mockContext.Context, scriptPath)) + 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) diff --git a/cli/azd/pkg/tools/script.go b/cli/azd/pkg/tools/script.go index 39b385aea7d..f6f2c280eab 100644 --- a/cli/azd/pkg/tools/script.go +++ b/cli/azd/pkg/tools/script.go @@ -10,22 +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 + + // StdOut overrides the default stdout for the process. + StdOut io.Writer } -// ScriptExecutor is the unified interface for all hook script execution. +// 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 script -type ScriptExecutor interface { - // Prepare performs pre-execution setup. For shell scripts this may - // validate tool availability; for language scripts this may create - // virtual environments and install dependencies. - Prepare(ctx context.Context, scriptPath string) error - - // Execute runs the script at the given path. - Execute(ctx context.Context, scriptPath string, options ExecOptions) (exec.RunResult, error) +// 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) } From 09439e608dcde39a5fb6eff2400a196bd69edb37 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 3 Apr 2026 12:11:05 -0700 Subject: [PATCH 8/9] fix: register HookExecutors in service hooks validation test The Test_ServiceHooks_ValidationUsesServicePath test from main was missing the registerHookExecutors call needed for IoC resolution of HookExecutor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/middleware/hooks_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/azd/cmd/middleware/hooks_test.go b/cli/azd/cmd/middleware/hooks_test.go index 79aa673df3b..15522c66ead 100644 --- a/cli/azd/cmd/middleware/hooks_test.go +++ b/cli/azd/cmd/middleware/hooks_test.go @@ -304,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" From 65168f0ab25aa38272ad5525162c65ba2f521ccd Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 3 Apr 2026 13:45:17 -0700 Subject: [PATCH 9/9] fix: adapt layer hook tests for HookExecutor architecture - Register hook executors (bash, powershell, python) in IoC container for tests that call hooksRunAction.Run(), fixing 'no concrete found for: tools.HookExecutor' errors in 3 layer hook tests - Update ValidatesLayerHooksRelativeToLayerPath assertion: validate() now infers ShellTypeBash from .sh file extension via validateLanguageRuntimes, so Shell is 'sh' not ScriptTypeUnknown Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/hooks_test.go | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) 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) }