From 9aafb4990d659128f230d7f788c342a2126b9321 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 1 Apr 2026 18:23:12 -0700 Subject: [PATCH 01/25] =?UTF-8?q?feat:=20add=20multi-language=20hook=20sup?= =?UTF-8?q?port=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_test.go | 329 ++++++ cli/azd/pkg/ext/models.go | 245 +++-- 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 + 17 files changed, 3756 insertions(+), 88 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_test.go b/cli/azd/pkg/ext/hooks_runner_test.go index a549774d4fa..1720e6ab9c6 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" @@ -453,6 +455,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 9f2dc24b849..f0596cf7dbd 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -4,25 +4,30 @@ package ext import ( - "crypto/sha256" - "encoding/hex" "errors" "fmt" - "maps" "os" "path/filepath" "runtime" - "slices" - "strconv" "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 ( @@ -42,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 @@ -71,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. @@ -86,8 +123,11 @@ type HookConfig struct { Secrets map[string]string `yaml:"secrets,omitempty"` } -// Validates and normalizes the hook configuration. -// For inline hooks this only resolves metadata; the executable temp script is generated per execution. +// 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 @@ -112,44 +152,87 @@ 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 { - if hc.location == ScriptLocationInline { - hc.Shell = getDefaultShellForOS() - hc.usingDefaultShell = true - } else { - scriptType, err := inferScriptTypeFromFilePath(hc.path) - if err != nil { - return err - } - - hc.Shell = scriptType + switch hc.Language { + case language.ScriptLanguageBash: + hc.Shell = ShellTypeBash + case language.ScriptLanguagePowerShell: + hc.Shell = ShellTypePowershell } } - hc.validated = true + // --- existing shell behaviour (unchanged) --- - return nil -} + // If shell is not specified and it's an inline script, use OS default + if hc.Shell == ScriptTypeUnknown && hc.path == "" { + hc.Shell = getDefaultShellForOS() + hc.usingDefaultShell = true + } + + if hc.location == ScriptLocationUnknown { + if hc.path != "" { + hc.location = ScriptLocationPath + } else if hc.script != "" { + hc.location = ScriptLocationInline + } + } -// PrepareExecutionPath returns the executable script path for the hook. -// Inline hooks get a fresh temp file per execution, while file hooks reuse their configured path. -func (hc *HookConfig) PrepareExecutionPath() (string, func(), error) { - if err := hc.validate(); err != nil { - return "", nil, err + if hc.location == ScriptLocationInline { + tempScript, err := createTempScript(hc) + if err != nil { + return err + } + + hc.path = tempScript } - if hc.location != ScriptLocationInline { - return hc.path, nil, nil + if hc.Shell == ScriptTypeUnknown { + scriptType, err := inferScriptTypeFromFilePath(hc.path) + if err != nil { + return err + } + + hc.Shell = scriptType } - tempScript, err := createTempScript(hc) - if err != nil { - return "", nil, err + // Backfill Language from resolved Shell for shell-based hooks. + if hc.Language == language.ScriptLanguageUnknown { + hc.Language = shellToLanguage(hc.Shell) } - return tempScript, func() { - _ = os.Remove(tempScript) - }, nil + hc.validated = true + + return nil } // IsPowerShellHook determines if a hook configuration uses PowerShell @@ -183,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 { @@ -196,56 +298,6 @@ func InferHookType(name string) (HookType, string) { return HookTypeNone, name } -// HooksConfigSignature returns a stable signature for a set of hook configurations. -// The signature ignores runtime-only state and changes whenever the effective hook config changes. -func HooksConfigSignature(hooks map[string][]*HookConfig) string { - if len(hooks) == 0 { - return "" - } - - var builder strings.Builder - - for _, hookName := range slices.Sorted(maps.Keys(hooks)) { - builder.WriteString(hookName) - builder.WriteByte('\x00') - - for index, hookConfig := range hooks[hookName] { - builder.WriteString(strconv.Itoa(index)) - builder.WriteByte('\x00') - appendHookConfigSignature(&builder, hookConfig) - } - } - - sum := sha256.Sum256([]byte(builder.String())) - return hex.EncodeToString(sum[:]) -} - -func appendHookConfigSignature(builder *strings.Builder, hookConfig *HookConfig) { - if hookConfig == nil { - builder.WriteString("\x00") - return - } - - builder.WriteString(string(hookConfig.Shell)) - builder.WriteByte('\x00') - builder.WriteString(hookConfig.Run) - builder.WriteByte('\x00') - builder.WriteString(strconv.FormatBool(hookConfig.ContinueOnError)) - builder.WriteByte('\x00') - builder.WriteString(strconv.FormatBool(hookConfig.Interactive)) - builder.WriteByte('\x00') - - for _, secretName := range slices.Sorted(maps.Keys(hookConfig.Secrets)) { - builder.WriteString(secretName) - builder.WriteByte('=') - builder.WriteString(hookConfig.Secrets[secretName]) - builder.WriteByte('\x00') - } - - appendHookConfigSignature(builder, hookConfig.Windows) - appendHookConfigSignature(builder, hookConfig.Posix) -} - // getDefaultShellForOS returns the default shell type based on the operating system func getDefaultShellForOS() ShellType { if runtime.GOOS == "windows" { @@ -254,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 8f16385ed61f21e5f89446013cefed3b984dea8d Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 1 Apr 2026 18:43:18 -0700 Subject: [PATCH 02/25] 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 | 271 +++++++++++++++++++++++++++----- cli/azd/pkg/ext/models.go | 2 +- cli/azd/pkg/ext/models_test.go | 7 +- 3 files changed, 235 insertions(+), 45 deletions(-) diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index cd8f0fcde7b..b54a78ef225 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,66 +173,248 @@ 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 } - scriptPath, cleanup, err := hookConfig.PrepareExecutionPath() + // 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 != "" { + boundaryDir = hookConfig.cwd + } + + // 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 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, + ) + 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. + 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) + 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 - // Then show the hook execution output within the console previewer pane + // 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, - }) + previewer := h.console.ShowPreviewer( + ctx, + &input.ShowPreviewerOptions{ + Prefix: " ", + Title: fmt.Sprintf("%s Hook Output", hookConfig.Name), + MaxLineCount: 8, + }, + ) options.StdOut = previewer - defer h.console.StopPreviewer(ctx, false) + return true } - options.UserPwsh = string(hookConfig.Shell) - log.Printf("Executing script '%s'\n", scriptPath) - res, err := script.Execute(ctx, scriptPath, *options) - if err != nil { - execErr := fmt.Errorf( - "'%s' hook failed with exit code: '%d', Path: '%s'. : %w", - hookConfig.Name, - res.ExitCode, - scriptPath, - err, - ) + return false +} - // 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 - } - } +// 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, + ) - // Delete any temporary inline scripts after execution. - // Removing temp scripts only on success to support better debugging with failing scripts. - if cleanup != nil { - defer cleanup() + 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 nil + return execErr } 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 388663e24157944857f002d6b97fd2412fb0df74 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 2 Apr 2026 09:41:43 -0700 Subject: [PATCH 03/25] 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 dc4e57ca600415993506aecf1c88b52f27fef594 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 2 Apr 2026 13:20:08 -0700 Subject: [PATCH 04/25] 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 601f4cb55c5e96c19e3b67c2749a7ab6fdcc80a7 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 2 Apr 2026 15:34:51 -0700 Subject: [PATCH 05/25] 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 fe88e8ff9d0823662f39fe6a8011e8a85b8bad48 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 2 Apr 2026 17:05:56 -0700 Subject: [PATCH 06/25] 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 | 332 +++++------------- 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(+), 532 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 1720e6ab9c6..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" @@ -202,50 +201,6 @@ func Test_Hooks_Execute(t *testing.T) { require.NoError(t, err) }) - t.Run("Inline Hook Can Run Twice", func(t *testing.T) { - var executedPaths []string - - mockContext := mocks.NewMockContext(context.Background()) - mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { - return len(args.Args) == 1 && strings.Contains(args.Args[0], "azd-preinline-") - }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { - executedPaths = append(executedPaths, args.Args[0]) - _, err := os.Stat(args.Args[0]) - require.NoError(t, err) - - return exec.NewRunResult(0, "", ""), nil - }) - - hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) - runner := NewHooksRunner( - hooksManager, - mockContext.CommandRunner, - envManager, - mockContext.Console, - cwd, - hooksMap, - env, - mockContext.Container, - ) - - err := runner.RunHooks(*mockContext.Context, HookTypePre, nil, "inline") - require.NoError(t, err) - require.Len(t, executedPaths, 1) - - _, err = os.Stat(executedPaths[0]) - require.Error(t, err) - require.True(t, os.IsNotExist(err)) - - err = runner.RunHooks(*mockContext.Context, HookTypePre, nil, "inline") - require.NoError(t, err) - require.Len(t, executedPaths, 2) - require.NotEqual(t, executedPaths[0], executedPaths[1]) - - _, err = os.Stat(executedPaths[1]) - require.Error(t, err) - require.True(t, os.IsNotExist(err)) - }) - t.Run("InvokeAction", func(t *testing.T) { ranPreHook := false ranPostHook := false @@ -306,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) @@ -318,141 +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("Bash", func(t *testing.T) { - hookConfig := hooksMap["bash"][0] + t.Run("BashHookExecutes", func(t *testing.T) { + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Shell: ShellTypeBash, + Run: "scripts/script.sh", + }}, + } + + 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.Equal(t, "echo 'hello'", hookConfig.script) - require.Empty(t, hookConfig.path) + 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.Empty(t, hookConfig.path) - 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 @@ -781,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 850683963e1ad9d889dd58c64fbd05117053f457 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 3 Apr 2026 11:13:00 -0700 Subject: [PATCH 07/25] 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 4593a80218c..d3c29691e0a 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 12b50fe8732..e15f2f7b1be 100644 --- a/cli/azd/cmd/middleware/hooks_test.go +++ b/cli/azd/cmd/middleware/hooks_test.go @@ -20,6 +20,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" @@ -29,6 +33,7 @@ import ( func Test_CommandHooks_Middleware_WithValidProjectAndMatchingCommand(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -63,6 +68,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" @@ -97,6 +103,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" @@ -123,6 +130,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" @@ -160,6 +168,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" @@ -198,6 +207,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" @@ -232,6 +242,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" @@ -514,6 +525,7 @@ services: func Test_PowerShellWarning_WithPowerShellHooks(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -562,6 +574,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" @@ -608,6 +621,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" @@ -686,6 +700,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" @@ -846,6 +861,7 @@ func Test_CommandHooks_ServiceHooks_RegisterForChildOnlyWorkflowRuns(t *testing. // 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" @@ -902,6 +918,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" @@ -939,6 +956,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" @@ -985,6 +1003,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" @@ -1030,3 +1049,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 b119bd101bb67fb2770aa18aed620ccd575e24ad Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 3 Apr 2026 12:11:05 -0700 Subject: [PATCH 08/25] 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 e15f2f7b1be..c6831e71c5a 100644 --- a/cli/azd/cmd/middleware/hooks_test.go +++ b/cli/azd/cmd/middleware/hooks_test.go @@ -305,6 +305,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 3b44f9fff1dc8e12638d21fc8123c89f6093fdd6 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Fri, 3 Apr 2026 13:45:17 -0700 Subject: [PATCH 09/25] 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) } From 60cad824063f299bb165d3e46407fd8ebee505d0 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Mon, 6 Apr 2026 12:03:38 -0700 Subject: [PATCH 10/25] refactor: move inline temp-file creation to executor Prepare() Each executor now handles its own temp file creation during Prepare(), eliminating shell-specific branching (createTempScript, ShellType switch) from the common hook model. - Add InlineScript and HookName fields to ExecutionContext - Add Cleanup() method to HookExecutor interface - Bash executor creates .sh temp files with shebang in Prepare() - PowerShell executor creates .ps1 temp files in Prepare() - Remove createTempScript() from models.go - Remove inline script cleanup from hooks_runner.go Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/ext/hooks_runner.go | 20 +++--- cli/azd/pkg/ext/models.go | 70 +------------------ cli/azd/pkg/tools/bash/bash.go | 54 +++++++++++++- cli/azd/pkg/tools/language/python_executor.go | 6 ++ cli/azd/pkg/tools/powershell/powershell.go | 63 ++++++++++++++++- cli/azd/pkg/tools/script.go | 26 ++++++- 6 files changed, 152 insertions(+), 87 deletions(-) diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index 9d1283b0620..db20715512d 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -178,9 +178,11 @@ func (h *HooksRunner) execHook( // Build execution context. execCtx := tools.ExecutionContext{ - Cwd: cwd, - EnvVars: envVars, - BoundaryDir: boundaryDir, + Cwd: cwd, + EnvVars: envVars, + BoundaryDir: boundaryDir, + InlineScript: hookConfig.script, + HookName: hookConfig.Name, } // Merge caller-provided overrides (e.g. forced interactive from 'azd hooks run'). @@ -216,7 +218,8 @@ func (h *HooksRunner) execHook( scriptPath = filepath.Join(hookConfig.cwd, hookConfig.path) } - // Prepare (unified — venv/deps for Python, pwsh detection for PS, no-op for bash). + // Prepare (unified — venv/deps for Python, pwsh detection for PS, + // inline temp file creation for shell hooks). log.Printf( "Preparing hook '%s' (%s)\n", hookConfig.Name, hookConfig.Language, @@ -242,6 +245,10 @@ func (h *HooksRunner) execHook( } } + // Cleanup temp resources created during Prepare (e.g. inline + // script temp files). Runs after Execute regardless of outcome. + defer executor.Cleanup(ctx) + // Configure console/previewer. if h.configureExecContext(ctx, hookConfig, &execCtx) { defer h.console.StopPreviewer(ctx, false) @@ -263,11 +270,6 @@ func (h *HooksRunner) execHook( } } - // Cleanup inline temp scripts. - if hookConfig.location == ScriptLocationInline { - defer os.Remove(hookConfig.path) - } - return nil } diff --git a/cli/azd/pkg/ext/models.go b/cli/azd/pkg/ext/models.go index c2123a2b72f..c581fda249a 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -11,7 +11,6 @@ import ( "runtime" "strings" - "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/tools/language" ) @@ -211,16 +210,7 @@ func (hc *HookConfig) validate() error { } } - if hc.location == ScriptLocationInline { - tempScript, err := createTempScript(hc) - if err != nil { - return err - } - - hc.path = tempScript - } - - if hc.Shell == ScriptTypeUnknown { + if hc.location != ScriptLocationInline && hc.Shell == ScriptTypeUnknown { scriptType, err := inferScriptTypeFromFilePath(hc.path) if err != nil { return err @@ -342,61 +332,3 @@ func inferScriptTypeFromFilePath(path string) (ShellType, error) { ) } } - -func createTempScript(hookConfig *HookConfig) (string, error) { - var ext string - scriptHeader := []string{} - scriptFooter := []string{} - - switch ShellType(strings.Split(string(hookConfig.Shell), " ")[0]) { - case ShellTypeBash: - ext = "sh" - scriptHeader = []string{ - "#!/bin/sh", - "set -e", - } - case ShellTypePowershell: - ext = "ps1" - scriptHeader = []string{ - "$ErrorActionPreference = 'Stop'", - } - scriptFooter = []string{ - "if ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) { exit $LASTEXITCODE }", - } - } - - // Write the temporary script file to OS temp dir - file, err := os.CreateTemp(os.TempDir(), fmt.Sprintf("azd-%s-*.%s", hookConfig.Name, ext)) - if err != nil { - return "", fmt.Errorf("failed creating hook file: %w", err) - } - - defer file.Close() - - scriptBuilder := strings.Builder{} - for _, line := range scriptHeader { - scriptBuilder.WriteString(fmt.Sprintf("%s\n", line)) - } - - scriptBuilder.WriteString("\n") - scriptBuilder.WriteString("# Auto generated file from Azure Developer CLI\n") - scriptBuilder.WriteString(hookConfig.script) - scriptBuilder.WriteString("\n") - - for _, line := range scriptFooter { - scriptBuilder.WriteString(fmt.Sprintf("%s\n", line)) - } - - // Temp generated files are cleaned up automatically after script execution has completed. - _, err = file.WriteString(scriptBuilder.String()) - if err != nil { - return "", fmt.Errorf("failed writing hook file, %w", err) - } - - // Update file permissions to grant exec permissions - if err := file.Chmod(osutil.PermissionExecutableFile); err != nil { - return "", fmt.Errorf("failed setting executable file permissions, %w", err) - } - - return file.Name(), nil -} diff --git a/cli/azd/pkg/tools/bash/bash.go b/cli/azd/pkg/tools/bash/bash.go index 0bb7f582f30..f49269af6ab 100644 --- a/cli/azd/pkg/tools/bash/bash.go +++ b/cli/azd/pkg/tools/bash/bash.go @@ -5,10 +5,13 @@ package bash import ( "context" + "fmt" + "os" "runtime" "strings" "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" ) @@ -19,18 +22,53 @@ func NewExecutor(commandRunner exec.CommandRunner) tools.HookExecutor { type bashExecutor struct { commandRunner exec.CommandRunner + tempFile string // temp script created from inline content } -// Prepare is a no-op for bash — bash is assumed available on all platforms. -func (b *bashExecutor) Prepare(_ context.Context, _ string, _ tools.ExecutionContext) error { +// Prepare creates a temp script file when the execution context +// carries inline script content. For file-based hooks this is a no-op. +func (b *bashExecutor) Prepare( + _ context.Context, _ string, execCtx tools.ExecutionContext, +) error { + if execCtx.InlineScript == "" { + return nil + } + + tmpFile, err := os.CreateTemp( + "", fmt.Sprintf("azd-%s-*.sh", execCtx.HookName), + ) + if err != nil { + return fmt.Errorf("creating temp script: %w", err) + } + + content := "#!/bin/sh\nset -e\n\n" + + "# Auto generated file from Azure Developer CLI\n" + + execCtx.InlineScript + "\n" + + if err := os.WriteFile( + tmpFile.Name(), []byte(content), osutil.PermissionExecutableFile, + ); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + return fmt.Errorf("writing temp script: %w", err) + } + + tmpFile.Close() + b.tempFile = tmpFile.Name() + return nil } -// Execute runs the specified bash script. +// Execute runs the specified bash script. When a temp file was +// created during Prepare it is used instead of the provided path. // When interactive is true will attach to stdin, stdout & stderr. func (b *bashExecutor) Execute( ctx context.Context, path string, execCtx tools.ExecutionContext, ) (exec.RunResult, error) { + if b.tempFile != "" { + path = b.tempFile + } + var runArgs exec.RunArgs // Bash likes all path separators in POSIX format path = strings.ReplaceAll(path, "\\", "/") @@ -56,3 +94,13 @@ func (b *bashExecutor) Execute( return b.commandRunner.Run(ctx, runArgs) } + +// Cleanup removes any temporary script file created during Prepare. +func (b *bashExecutor) Cleanup(_ context.Context) error { + if b.tempFile != "" { + err := os.Remove(b.tempFile) + b.tempFile = "" + return err + } + return nil +} diff --git a/cli/azd/pkg/tools/language/python_executor.go b/cli/azd/pkg/tools/language/python_executor.go index 412cfc361a7..768d5d30a37 100644 --- a/cli/azd/pkg/tools/language/python_executor.go +++ b/cli/azd/pkg/tools/language/python_executor.go @@ -228,6 +228,12 @@ func (e *pythonExecutor) Execute( return e.commandRunner.Run(ctx, runArgs) } +// Cleanup is a no-op for the Python executor — no temporary +// resources are created during Prepare. +func (e *pythonExecutor) Cleanup(_ context.Context) error { + return nil +} + // 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 diff --git a/cli/azd/pkg/tools/powershell/powershell.go b/cli/azd/pkg/tools/powershell/powershell.go index 598dfb09b84..53b0b4c1941 100644 --- a/cli/azd/pkg/tools/powershell/powershell.go +++ b/cli/azd/pkg/tools/powershell/powershell.go @@ -6,10 +6,12 @@ package powershell import ( "context" "fmt" + "os" "runtime" "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/osutil" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/tools" ) @@ -25,14 +27,53 @@ func NewExecutor(commandRunner exec.CommandRunner) tools.HookExecutor { type powershellExecutor struct { commandRunner exec.CommandRunner shellCmd string // resolved in Prepare: "pwsh" or "powershell" + tempFile string // temp script created from inline content } // 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. +// guidance if neither is found. When the execution context carries +// inline script content, a temp .ps1 file is created. func (p *powershellExecutor) Prepare( - _ context.Context, _ string, _ tools.ExecutionContext, + _ context.Context, _ string, execCtx tools.ExecutionContext, ) error { + // Resolve PowerShell binary. + if err := p.resolvePowerShell(); err != nil { + return err + } + + // Create temp file for inline scripts. + if execCtx.InlineScript != "" { + tmpFile, err := os.CreateTemp( + "", fmt.Sprintf("azd-%s-*.ps1", execCtx.HookName), + ) + if err != nil { + return fmt.Errorf("creating temp script: %w", err) + } + + content := "$ErrorActionPreference = 'Stop'\n\n" + + "# Auto generated file from Azure Developer CLI\n" + + execCtx.InlineScript + "\n" + + "if ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) " + + "{ exit $LASTEXITCODE }\n" + + if err := os.WriteFile( + tmpFile.Name(), []byte(content), osutil.PermissionExecutableFile, + ); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + return fmt.Errorf("writing temp script: %w", err) + } + + tmpFile.Close() + p.tempFile = tmpFile.Name() + } + + return nil +} + +// resolvePowerShell locates pwsh or powershell in PATH. +func (p *powershellExecutor) resolvePowerShell() error { // Try pwsh first. if p.commandRunner.ToolInPath("pwsh") == nil { p.shellCmd = "pwsh" @@ -66,10 +107,16 @@ func (p *powershellExecutor) Prepare( } } -// Execute runs the PowerShell script using the shell resolved in Prepare. +// Execute runs the PowerShell script using the shell resolved in +// Prepare. When a temp file was created during Prepare it is used +// instead of the provided path. func (p *powershellExecutor) Execute( ctx context.Context, path string, execCtx tools.ExecutionContext, ) (exec.RunResult, error) { + if p.tempFile != "" { + path = p.tempFile + } + runArgs := exec.NewRunArgs(p.shellCmd, path). WithCwd(execCtx.Cwd). WithEnv(execCtx.EnvVars). @@ -85,3 +132,13 @@ func (p *powershellExecutor) Execute( return p.commandRunner.Run(ctx, runArgs) } + +// Cleanup removes any temporary script file created during Prepare. +func (p *powershellExecutor) Cleanup(_ context.Context) error { + if p.tempFile != "" { + err := os.Remove(p.tempFile) + p.tempFile = "" + return err + } + return nil +} diff --git a/cli/azd/pkg/tools/script.go b/cli/azd/pkg/tools/script.go index f6f2c280eab..bd3d073372a 100644 --- a/cli/azd/pkg/tools/script.go +++ b/cli/azd/pkg/tools/script.go @@ -32,17 +32,37 @@ type ExecutionContext struct { // StdOut overrides the default stdout for the process. StdOut io.Writer + + // InlineScript contains the raw script content for inline hooks. + // When set, the executor creates a temp file in Prepare() with + // the appropriate extension and content wrapper (e.g., shebang + // for bash). When empty, scriptPath is used directly as a file + // path. + InlineScript string + + // HookName is the descriptive name of the hook (e.g., + // "preprovision"). Used by executors for temp file naming to + // aid debuggability. + HookName string } // HookExecutor is the unified interface for all hook execution. -// Every executor follows a two-phase lifecycle: -// 1. Prepare — validate prerequisites, resolve tools, install dependencies +// Every executor follows a three-phase lifecycle: +// 1. Prepare — validate prerequisites, resolve tools, create temp files // 2. Execute — run the hook +// 3. Cleanup — remove temporary resources created during Prepare type HookExecutor interface { // Prepare performs pre-execution setup such as runtime validation, - // virtual environment creation, or dependency installation. + // virtual environment creation, dependency installation, or temp + // file creation for inline scripts. 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) + + // Cleanup removes any temporary resources created during Prepare. + // Called by the hooks runner after Execute completes, regardless + // of success or failure. Implementations must be safe to call + // even when Prepare was not called or created no resources. + Cleanup(ctx context.Context) error } From 31c9683967f501dd18a07553538b5e71555b05ee Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Mon, 6 Apr 2026 12:37:25 -0700 Subject: [PATCH 11/25] refactor: eliminate script-vs-language distinction from common hook components Remove all leakage of shell/script vs language concepts from the hooks runner, manager, and model. With the unified HookExecutor interface, common components no longer branch on hook type. - Move inline temp-file creation to executor Prepare() (bash/powershell handle their own .sh/.ps1 files with shebangs) - Add Cleanup() to HookExecutor lifecycle (Prepare > Execute > Cleanup) - Add InlineScript/HookName fields to ExecutionContext - Unify path/cwd resolution for all executor types - Remove ShellType enum, shellToLanguage(), inferScriptTypeFromFilePath(), createTempScript(), ScriptLocation enum, IsLanguageHook() method - Replace IsLanguageHook() with ScriptLanguage.IsShellLanguage() - Rename validateLanguageRuntimes to validateRuntimes - Update all docs and comments to reflect unified model Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/hooks_test.go | 14 +- cli/azd/cmd/middleware/hooks_test.go | 32 +-- cli/azd/docs/language-hooks.md | 83 ++++---- cli/azd/pkg/ext/hooks_config_test.go | 12 +- cli/azd/pkg/ext/hooks_manager.go | 52 ++--- cli/azd/pkg/ext/hooks_manager_test.go | 35 ++-- cli/azd/pkg/ext/hooks_runner.go | 24 +-- cli/azd/pkg/ext/hooks_runner_test.go | 55 +++-- cli/azd/pkg/ext/models.go | 226 +++++++++------------ cli/azd/pkg/ext/models_test.go | 61 +++--- cli/azd/pkg/ext/python_hooks_e2e_test.go | 18 +- cli/azd/pkg/project/project_config_test.go | 12 +- cli/azd/pkg/project/project_test.go | 13 +- cli/azd/pkg/tools/language/executor.go | 10 + cli/azd/pkg/tools/script.go | 2 +- 15 files changed, 319 insertions(+), 330 deletions(-) diff --git a/cli/azd/cmd/hooks_test.go b/cli/azd/cmd/hooks_test.go index 7d2ab480c67..f84eb2ea7c6 100644 --- a/cli/azd/cmd/hooks_test.go +++ b/cli/azd/cmd/hooks_test.go @@ -65,7 +65,7 @@ func Test_HooksRunAction_RunsLayerHooks(t *testing.T) { Path: "infra/core", Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: ext.ShellTypeBash, + Shell: "sh", Run: "echo core", }}, }, @@ -75,7 +75,7 @@ func Test_HooksRunAction_RunsLayerHooks(t *testing.T) { Path: absoluteLayerPath, Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: ext.ShellTypeBash, + Shell: "sh", Run: "echo shared", }}, }, @@ -133,7 +133,7 @@ func Test_HooksRunAction_FiltersLayerHooks(t *testing.T) { Path: "infra/core", Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: ext.ShellTypeBash, + Shell: "sh", Run: "echo core", }}, }, @@ -143,7 +143,7 @@ func Test_HooksRunAction_FiltersLayerHooks(t *testing.T) { Path: "infra/shared", Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: ext.ShellTypeBash, + Shell: "sh", Run: "echo shared", }}, }, @@ -203,7 +203,7 @@ func Test_HooksRunAction_SetsTelemetryTypeForLayer(t *testing.T) { Path: "infra/core", Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: ext.ShellTypeBash, + Shell: "sh", Run: "echo core", }}, }, @@ -297,6 +297,6 @@ func Test_HooksRunAction_ValidatesLayerHooksRelativeToLayerPath(t *testing.T) { err := action.validateAndWarnHooks(*mockContext.Context) require.NoError(t, err) require.False(t, layerHook.IsUsingDefaultShell()) - // validate() infers shell type from the .sh file extension - require.Equal(t, ext.ShellTypeBash, layerHook.Shell) + // validate() infers language from the .sh file extension + require.Equal(t, language.ScriptLanguageBash, layerHook.Language) } diff --git a/cli/azd/cmd/middleware/hooks_test.go b/cli/azd/cmd/middleware/hooks_test.go index c6831e71c5a..6f48fc09ff0 100644 --- a/cli/azd/cmd/middleware/hooks_test.go +++ b/cli/azd/cmd/middleware/hooks_test.go @@ -45,7 +45,7 @@ func Test_CommandHooks_Middleware_WithValidProjectAndMatchingCommand(t *testing. "precommand": { { Run: "echo 'hello'", - Shell: ext.ShellTypeBash, + Shell: "sh", }, }, }, @@ -80,7 +80,7 @@ func Test_CommandHooks_Middleware_ValidProjectWithDifferentCommand(t *testing.T) "precommand": { { Run: "echo 'hello'", - Shell: ext.ShellTypeBash, + Shell: "sh", }, }, }, @@ -142,7 +142,7 @@ func Test_CommandHooks_Middleware_PreHookWithError(t *testing.T) { "precommand": { { Run: "exit 1", - Shell: ext.ShellTypeBash, + Shell: "sh", }, }, }, @@ -180,7 +180,7 @@ func Test_CommandHooks_Middleware_PreHookWithErrorAndContinue(t *testing.T) { "precommand": { { Run: "exit 1", - Shell: ext.ShellTypeBash, + Shell: "sh", ContinueOnError: true, }, }, @@ -219,7 +219,7 @@ func Test_CommandHooks_Middleware_WithCmdAlias(t *testing.T) { "prealias": { { Run: "echo 'hello'", - Shell: ext.ShellTypeBash, + Shell: "sh", }, }, }, @@ -261,7 +261,7 @@ func Test_ServiceHooks_Registered(t *testing.T) { Hooks: map[string][]*ext.HookConfig{ "predeploy": { { - Shell: ext.ShellTypeBash, + Shell: "sh", Run: "echo 'Hello'", }, }, @@ -538,7 +538,7 @@ func Test_PowerShellWarning_WithPowerShellHooks(t *testing.T) { "preprovision": { { Run: "Write-Host 'hello'", - Shell: ext.ShellTypePowershell, + Shell: "pwsh", }, }, }, @@ -586,8 +586,8 @@ func Test_PowerShellWarning_WithPs1FileHook(t *testing.T) { Hooks: map[string][]*ext.HookConfig{ "preprovision": { { - Run: "script.ps1", // PowerShell file extension - Shell: ext.ShellTypePowershell, // Explicitly specify shell to avoid detection issues + Run: "script.ps1", // PowerShell file extension + Shell: "pwsh", // Explicitly specify shell to avoid detection issues }, }, }, @@ -634,7 +634,7 @@ func Test_PowerShellWarning_WithoutPowerShellHooks(t *testing.T) { "precommand": { { Run: "echo 'hello'", - Shell: ext.ShellTypeBash, + Shell: "sh", }, }, }, @@ -713,7 +713,7 @@ func Test_CommandHooks_ChildAction_HooksStillFire(t *testing.T) { tt.hookName: { { Run: "echo 'hook running'", - Shell: ext.ShellTypeBash, + Shell: "sh", }, }, }, @@ -874,13 +874,13 @@ func Test_CommandHooks_ChildAction_SkipsValidationOnly(t *testing.T) { "preprovision": { { Run: "echo 'preprovision hook'", - Shell: ext.ShellTypeBash, + Shell: "sh", }, }, "postprovision": { { Run: "echo 'postprovision hook'", - Shell: ext.ShellTypeBash, + Shell: "sh", }, }, }, @@ -931,7 +931,7 @@ func Test_CommandHooks_ChildAction_PreHookError_StopsAction(t *testing.T) { "preprovision": { { Run: "exit 1", - Shell: ext.ShellTypeBash, + Shell: "sh", }, }, }, @@ -969,7 +969,7 @@ func Test_PowerShellWarning_WithPwshAvailable(t *testing.T) { "precommand": { { Run: "Write-Host 'hello'", - Shell: ext.ShellTypePowershell, + Shell: "pwsh", }, }, }, @@ -1016,7 +1016,7 @@ func Test_PowerShellWarning_WithNoPowerShellInstalled(t *testing.T) { "preprovision": { { Run: "Write-Host 'hello'", - Shell: ext.ShellTypePowershell, + Shell: "pwsh", }, }, }, diff --git a/cli/azd/docs/language-hooks.md b/cli/azd/docs/language-hooks.md index d0e226ffdbd..382c2cc347e 100644 --- a/cli/azd/docs/language-hooks.md +++ b/cli/azd/docs/language-hooks.md @@ -1,12 +1,12 @@ -# Language Hooks +# 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. +Azure Developer CLI hooks support multiple executor types — Bash, PowerShell, +Python (and future JavaScript, TypeScript, .NET). Every hook follows the same +unified lifecycle regardless of its executor: **Prepare → Execute → Cleanup**. -## Supported Languages +## Supported Executor Types -| Language | `language` value | File extension | Status | +| Executor | `language` value | File extension | Status | |------------|-----------------|----------------|--------------| | Bash | `sh` | `.sh` | ✅ Stable | | PowerShell | `pwsh` | `.ps1` | ✅ Stable | @@ -17,21 +17,21 @@ automatically handles dependency installation and runtime management. ## Configuration -Language hooks are configured in `azure.yaml` under the `hooks` section at the -project or service level. Two new optional fields are available: +Hooks are configured in `azure.yaml` under the `hooks` section at the +project or service level. Two optional fields are available: ### `language` (string, optional) -Specifies the programming language of the hook script. Allowed values: +Specifies the executor type for the hook. 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`. +When omitted, the executor is **auto-detected** from the file extension of the +`run` path. For example, `run: ./hooks/seed.py` automatically selects the +Python executor. ### `dir` (string, optional) -Specifies the working directory for language hook execution, used as the project +Specifies the working directory for hook execution, used as the project context for dependency installation (e.g. `pip install` from `requirements.txt`) and builds. @@ -47,7 +47,7 @@ Relative paths are resolved from the project or service root. ### Python hook — auto-detected from .py extension -The simplest way to use a Python hook. The language is inferred from the `.py` +The simplest way to use a Python hook. The executor is inferred from the `.py` extension, and the working directory is auto-inferred from the script's location. Dependencies are installed automatically if a `requirements.txt` or `pyproject.toml` is found in the script's directory. @@ -73,7 +73,7 @@ hooks: ### Python hook — explicit language When auto-detection is not desired or the file extension is ambiguous, set -the `language` field explicitly: +the `language` field explicitly to select the Python executor: ```yaml hooks: @@ -113,8 +113,8 @@ hooks: ### Python hook with secrets -Language hooks support the same `secrets` field as shell hooks for -resolving Azure Key Vault references: +Hooks support the `secrets` field for resolving Azure Key Vault references, +regardless of executor type: ```yaml hooks: @@ -124,10 +124,10 @@ hooks: DB_CONNECTION_STRING: DATABASE_URL ``` -### Shell hook (existing behavior, unchanged) +### Bash hook (existing behavior, unchanged) -Shell hooks continue to work exactly as before. The `language` field is -optional and defaults to the shell type: +Bash hooks continue to work exactly as before. The `language` field is +optional and defaults to the appropriate shell type: ```yaml hooks: @@ -138,25 +138,34 @@ hooks: ## 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. +Every hook follows the unified **Prepare → Execute → Cleanup** lifecycle: + +1. **Prepare** — The executor validates prerequisites and performs any + setup. This includes: + - **Language detection** from the explicit `language` field, the + `shell` field, or the file extension of the `run` path. + - **Runtime validation** — verifying the required runtime is + installed (e.g. Python 3 for `.py` hooks, pwsh for `.ps1`). + - **Project discovery** — walking 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. + - **Dependency installation** — creating a virtual environment + (for Python) and installing dependencies from the discovered + project file. + - **Temp file creation** — for inline scripts (Bash/PowerShell + only), writing the script content to a temporary file. +2. **Execute** — The executor runs the hook using the appropriate + runtime (e.g. `python`, `bash`, `pwsh`). +3. **Cleanup** — The executor removes any temporary resources created + during Prepare (e.g. inline script temp files). This runs regardless + of whether Execute succeeded or failed. ## 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. +- **Inline scripts** are only supported for Bash and PowerShell hooks. + All other executor types must reference a file path. +- **Phase 1** supports only Python as a non-shell executor. 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_config_test.go b/cli/azd/pkg/ext/hooks_config_test.go index b298553dea2..37164264ec6 100644 --- a/cli/azd/pkg/ext/hooks_config_test.go +++ b/cli/azd/pkg/ext/hooks_config_test.go @@ -26,7 +26,7 @@ preprovision: require.Equal(t, HooksConfig{ "preprovision": { { - Shell: ShellTypeBash, + Shell: "sh", Run: "scripts/preprovision.sh", }, }, @@ -49,11 +49,11 @@ preprovision: require.Equal(t, HooksConfig{ "preprovision": { { - Shell: ShellTypeBash, + Shell: "sh", Run: "scripts/preprovision-1.sh", }, { - Shell: ShellTypeBash, + Shell: "sh", Run: "scripts/preprovision-2.sh", }, }, @@ -66,7 +66,7 @@ func TestHooksConfig_MarshalYAML(t *testing.T) { hooks := HooksConfig{ "preprovision": { { - Shell: ShellTypeBash, + Shell: "sh", Run: "scripts/preprovision.sh", }, }, @@ -86,11 +86,11 @@ preprovision: hooks := HooksConfig{ "preprovision": { { - Shell: ShellTypeBash, + Shell: "sh", Run: "scripts/preprovision-1.sh", }, { - Shell: ShellTypeBash, + Shell: "sh", Run: "scripts/preprovision-2.sh", }, }, diff --git a/cli/azd/pkg/ext/hooks_manager.go b/cli/azd/pkg/ext/hooks_manager.go index d0fdb2c94a1..6c6a657f9bc 100644 --- a/cli/azd/pkg/ext/hooks_manager.go +++ b/cli/azd/pkg/ext/hooks_manager.go @@ -23,8 +23,8 @@ import ( type HookFilterPredicateFn func(scriptName string, hookConfig *HookConfig) bool -// Hooks enable support to invoke integration scripts before & after commands -// Scripts can be invoked at the project or service level or +// HooksManager enables support to invoke lifecycle hooks before & after +// commands. Hooks can be invoked at the project or service level. type HooksManager struct { cwd string commandRunner exec.CommandRunner @@ -175,13 +175,12 @@ func (h *HooksManager) ValidateHooks(ctx context.Context, allHooks map[string][] _, err := os.Stat(fullCheckPath) isInlineScript := err != nil // File doesn't exist, so it's inline - // If shell is not specified and it's an inline script, set default for warning - if hookConfig.Shell == ScriptTypeUnknown && isInlineScript { - if runtime.GOOS == "windows" { - hookConfig.Shell = ShellTypePowershell - } else { - hookConfig.Shell = ShellTypeBash - } + // If no language/shell and it's an inline script, set + // OS default Language for warning purposes. + if hookConfig.Shell == "" && + hookConfig.Language == language.ScriptLanguageUnknown && + isInlineScript { + hookConfig.Language = defaultLanguageForOS() hookConfig.usingDefaultShell = true } } @@ -249,21 +248,21 @@ 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) + // Check runtime availability for hooks that require external runtimes. + langWarnings := h.validateRuntimes(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. +// validateRuntimes inspects all hook configurations and verifies that +// required 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( +func (h *HooksManager) validateRuntimes( ctx context.Context, allHooks map[string][]*HookConfig, ) []HookWarning { @@ -307,7 +306,10 @@ func (h *HooksManager) validateLanguageRuntimes( continue } - if cfg.IsLanguageHook() { + // Non-shell hooks need runtime validation + // (e.g. Python must be installed). Bash and + // PowerShell hooks are validated separately above. + if !cfg.Language.IsShellLanguage() { if _, seen := requiredLangs[cfg.Language]; !seen { requiredLangs[cfg.Language] = hookName } @@ -342,16 +344,16 @@ func (h *HooksManager) validateLanguageRuntimes( 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( +// ValidateRuntimesErr is a convenience wrapper around ValidateHooks +// that returns an [errorhandler.ErrorWithSuggestion] when any required +// 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) ValidateRuntimesErr( ctx context.Context, allHooks map[string][]*HookConfig, ) error { - warnings := h.validateLanguageRuntimes(ctx, allHooks) + warnings := h.validateRuntimes(ctx, allHooks) if len(warnings) == 0 { return nil } @@ -369,7 +371,7 @@ func (h *HooksManager) ValidateLanguageRuntimesErr( return &errorhandler.ErrorWithSuggestion{ Err: fmt.Errorf( - "missing required language runtime: %s", + "missing required runtime: %s", first.Message, ), Message: first.Message, diff --git a/cli/azd/pkg/ext/hooks_manager_test.go b/cli/azd/pkg/ext/hooks_manager_test.go index d2273933a88..d8fca78c720 100644 --- a/cli/azd/pkg/ext/hooks_manager_test.go +++ b/cli/azd/pkg/ext/hooks_manager_test.go @@ -13,6 +13,7 @@ import ( "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/mockexec" "github.com/azure/azure-dev/cli/azd/test/ostest" "github.com/stretchr/testify/require" @@ -51,13 +52,13 @@ func Test_GetAllHookConfigs(t *testing.T) { hooksMap := map[string][]*HookConfig{ "preinit": { { - Shell: ShellTypeBash, + Shell: "sh", // Run is missing - this should cause an error }, }, "postinit": { { - Shell: ShellTypeBash, + Shell: "sh", // Run is missing - this should cause an error }, }, @@ -123,13 +124,13 @@ func Test_GetByParams(t *testing.T) { hooksMap := map[string][]*HookConfig{ "preinit": { { - Shell: ShellTypeBash, + Shell: "sh", // Run is missing - this should cause an error }, }, "postinit": { { - Shell: ShellTypeBash, + Shell: "sh", // Run is missing - this should cause an error }, }, @@ -150,7 +151,7 @@ func Test_HookConfig_DefaultShell(t *testing.T) { tests := []struct { name string hookConfig *HookConfig - expectedShell ShellType + expectedLanguage language.ScriptLanguage expectingDefault bool }{ { @@ -159,17 +160,17 @@ func Test_HookConfig_DefaultShell(t *testing.T) { Name: "test", Run: "echo 'hello'", }, - expectedShell: getDefaultShellForOS(), + expectedLanguage: defaultLanguageForOS(), expectingDefault: true, }, { name: "Shell explicitly specified - should not use default", hookConfig: &HookConfig{ Name: "test", - Shell: ShellTypeBash, + Shell: "sh", Run: "echo 'hello'", }, - expectedShell: ShellTypeBash, + expectedLanguage: language.ScriptLanguageBash, expectingDefault: false, }, } @@ -183,7 +184,7 @@ func Test_HookConfig_DefaultShell(t *testing.T) { err := config.validate() require.NoError(t, err) - require.Equal(t, tt.expectedShell, config.Shell) + require.Equal(t, tt.expectedLanguage, config.Language) require.Equal(t, tt.expectingDefault, config.IsUsingDefaultShell()) }) } @@ -222,7 +223,7 @@ func Test_ValidateHooks_PythonInstalled(t *testing.T) { mgr := NewHooksManager(tempDir, mockRunner) result := mgr.ValidateHooks(t.Context(), hooksMap) - // No language-runtime warnings should be present. + // No runtime warnings should be present. for _, w := range result.Warnings { require.NotContains(t, w.Message, "Python", "expected no Python warning when runtime is installed") @@ -230,7 +231,7 @@ func Test_ValidateHooks_PythonInstalled(t *testing.T) { // Also verify the error-returning variant. require.NoError(t, - mgr.ValidateLanguageRuntimesErr(t.Context(), hooksMap)) + mgr.ValidateRuntimesErr(t.Context(), hooksMap)) } func Test_ValidateHooks_PythonNotInstalled(t *testing.T) { @@ -279,7 +280,7 @@ func Test_ValidateHooks_PythonNotInstalled(t *testing.T) { // Verify the error-returning variant surfaces an // ErrorWithSuggestion. - err := mgr.ValidateLanguageRuntimesErr(t.Context(), hooksMap) + err := mgr.ValidateRuntimesErr(t.Context(), hooksMap) require.Error(t, err) require.Contains(t, err.Error(), "Python") } @@ -288,7 +289,7 @@ func Test_ValidateHooks_ShellHookNoValidation(t *testing.T) { tempDir := t.TempDir() ostest.Chdir(t, tempDir) - // Create shell scripts only — no language hooks. + // Create Bash scripts only — no non-shell hooks. scriptDir := filepath.Join(tempDir, "scripts") require.NoError(t, os.MkdirAll(scriptDir, osutil.PermissionDirectory)) require.NoError(t, @@ -320,14 +321,14 @@ func Test_ValidateHooks_ShellHookNoValidation(t *testing.T) { mgr := NewHooksManager(tempDir, mockRunner) result := mgr.ValidateHooks(t.Context(), hooksMap) - // No language-runtime warnings for shell hooks. + // No runtime warnings for Bash/PowerShell hooks. for _, w := range result.Warnings { require.NotContains(t, w.Message, "Python", - "shell-only hooks must not trigger language warnings") + "shell-only hooks must not trigger runtime warnings") } require.NoError(t, - mgr.ValidateLanguageRuntimesErr(t.Context(), hooksMap)) + mgr.ValidateRuntimesErr(t.Context(), hooksMap)) } func Test_ValidateHooks_MixedHooks(t *testing.T) { @@ -383,7 +384,7 @@ func Test_ValidateHooks_MixedHooks(t *testing.T) { require.Equal(t, 1, pythonWarnings, "expected exactly one Python warning for mixed hooks") - err := mgr.ValidateLanguageRuntimesErr(t.Context(), hooksMap) + err := mgr.ValidateRuntimesErr(t.Context(), hooksMap) require.Error(t, err) require.Contains(t, err.Error(), "Python") } diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index db20715512d..906e778540c 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -21,8 +21,8 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/tools" ) -// Hooks enable support to invoke integration scripts before & after commands -// Scripts can be invoked at the project or service level or +// HooksRunner enables support to invoke lifecycle hooks before & after +// commands. Hooks can be invoked at the project or service level. type HooksRunner struct { hooksManager *HooksManager commandRunner exec.CommandRunner @@ -168,7 +168,7 @@ func (h *HooksRunner) execHook( dir = filepath.Join(boundaryDir, dir) } cwd = dir - } else if hookConfig.path != "" && hookConfig.IsLanguageHook() { + } else if hookConfig.path != "" { cwd = filepath.Dir( filepath.Join(boundaryDir, hookConfig.path), ) @@ -210,16 +210,15 @@ func (h *HooksRunner) execHook( } } - // 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. + // Resolve script path to absolute so every executor receives a + // consistent, fully-qualified path regardless of hook language. scriptPath := hookConfig.path - if hookConfig.cwd != "" && hookConfig.IsLanguageHook() { - scriptPath = filepath.Join(hookConfig.cwd, hookConfig.path) + if hookConfig.path != "" { + scriptPath = filepath.Join(boundaryDir, hookConfig.path) } - // Prepare (unified — venv/deps for Python, pwsh detection for PS, - // inline temp file creation for shell hooks). + // Prepare (unified — venv/deps for Python, pwsh detection for + // PowerShell, inline temp file creation for Bash/PowerShell hooks). log.Printf( "Preparing hook '%s' (%s)\n", hookConfig.Name, hookConfig.Language, @@ -275,9 +274,8 @@ func (h *HooksRunner) execHook( // 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. +// stdout. Returns true when a previewer was started; the caller must +// defer [input.Console.StopPreviewer] in that case. func (h *HooksRunner) configureExecContext( ctx context.Context, hookConfig *HookConfig, diff --git a/cli/azd/pkg/ext/hooks_runner_test.go b/cli/azd/pkg/ext/hooks_runner_test.go index 99e26191883..2bab861d959 100644 --- a/cli/azd/pkg/ext/hooks_runner_test.go +++ b/cli/azd/pkg/ext/hooks_runner_test.go @@ -47,6 +47,7 @@ func registerHookExecutors(mockCtx *mocks.MockContext) { func Test_Hooks_Execute(t *testing.T) { cwd := t.TempDir() ostest.Chdir(t, cwd) + scriptsDir := filepath.Join(cwd, "scripts") env := environment.NewWithValues( "test", @@ -59,24 +60,24 @@ func Test_Hooks_Execute(t *testing.T) { hooksMap := map[string][]*HookConfig{ "preinline": { { - Shell: ShellTypeBash, + Shell: "sh", Run: "echo 'Hello'", }, }, "precommand": { { - Shell: ShellTypeBash, + Shell: "sh", Run: "scripts/precommand.sh", }, }, "postcommand": {{ - Shell: ShellTypeBash, + Shell: "sh", Run: "scripts/postcommand.sh", }, }, "preinteractive": { { - Shell: ShellTypeBash, + Shell: "sh", Run: "scripts/preinteractive.sh", Interactive: true, }, @@ -98,8 +99,10 @@ func Test_Hooks_Execute(t *testing.T) { return strings.Contains(command, "precommand.sh") }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { ranPreHook = true - require.Equal(t, "scripts/precommand.sh", args.Args[0]) - require.Equal(t, cwd, args.Cwd) + require.Equal(t, filepath.ToSlash( + filepath.Join(scriptsDir, "precommand.sh"), + ), args.Args[0]) + require.Equal(t, scriptsDir, args.Cwd) require.ElementsMatch(t, env.Environ(), args.Env) require.Equal(t, false, args.Interactive) @@ -134,8 +137,10 @@ func Test_Hooks_Execute(t *testing.T) { return strings.Contains(command, "postcommand.sh") }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { ranPostHook = true - require.Equal(t, "scripts/postcommand.sh", args.Args[0]) - require.Equal(t, cwd, args.Cwd) + require.Equal(t, filepath.ToSlash( + filepath.Join(scriptsDir, "postcommand.sh"), + ), args.Args[0]) + require.Equal(t, scriptsDir, args.Cwd) require.ElementsMatch(t, env.Environ(), args.Env) require.Equal(t, false, args.Interactive) @@ -170,8 +175,10 @@ func Test_Hooks_Execute(t *testing.T) { return strings.Contains(command, "preinteractive.sh") }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { ranPostHook = true - require.Equal(t, "scripts/preinteractive.sh", args.Args[0]) - require.Equal(t, cwd, args.Cwd) + require.Equal(t, filepath.ToSlash( + filepath.Join(scriptsDir, "preinteractive.sh"), + ), args.Args[0]) + require.Equal(t, scriptsDir, args.Cwd) require.ElementsMatch(t, env.Environ(), args.Env) require.Equal(t, true, args.Interactive) @@ -242,7 +249,9 @@ func Test_Hooks_Execute(t *testing.T) { }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { ranPreHook = true hookLog = append(hookLog, "pre") - require.Equal(t, "scripts/precommand.sh", args.Args[0]) + require.Equal(t, filepath.ToSlash( + filepath.Join(scriptsDir, "precommand.sh"), + ), args.Args[0]) return exec.NewRunResult(0, "", ""), nil }) @@ -252,7 +261,9 @@ func Test_Hooks_Execute(t *testing.T) { }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { ranPostHook = true hookLog = append(hookLog, "post") - require.Equal(t, "scripts/postcommand.sh", args.Args[0]) + require.Equal(t, filepath.ToSlash( + filepath.Join(scriptsDir, "postcommand.sh"), + ), args.Args[0]) return exec.NewRunResult(0, "", ""), nil }) @@ -320,7 +331,7 @@ func Test_Hooks_Validation(t *testing.T) { hooksMap := map[string][]*HookConfig{ "predeploy": {{ Name: "predeploy", - Shell: ShellTypeBash, + Shell: "sh", Run: "scripts/script.sh", }}, } @@ -382,7 +393,7 @@ func Test_Hooks_Validation(t *testing.T) { hooksMap := map[string][]*HookConfig{ "preinline": {{ Name: "preinline", - Shell: ShellTypeBash, + Shell: "sh", Run: "echo 'Hello'", }}, } @@ -412,7 +423,7 @@ func Test_Hooks_Validation(t *testing.T) { hooksMap := map[string][]*HookConfig{ "predeploy": {{ Name: "predeploy", - Shell: ShellTypeBash, + Shell: "sh", }}, } @@ -503,7 +514,7 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { require.True(t, executeRan, "Execute should have run the .py script") }) - t.Run("ShellHookUnchanged", func(t *testing.T) { + t.Run("ShellHookAbsolutePath", func(t *testing.T) { cwd := t.TempDir() ostest.Chdir(t, cwd) @@ -513,7 +524,7 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { "predeploy": { { Name: "predeploy", - Shell: ShellTypeBash, + Shell: "sh", Run: "scripts/predeploy.sh", }, }, @@ -531,8 +542,10 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { 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) + require.Equal(t, filepath.ToSlash( + filepath.Join(cwd, "scripts", "predeploy.sh"), + ), args.Args[0]) + require.Equal(t, filepath.Join(cwd, "scripts"), args.Cwd) return exec.NewRunResult(0, "", ""), nil }) @@ -553,7 +566,7 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { ) require.NoError(t, err) - require.True(t, shellRan, "Shell script path should be used for .sh hooks") + require.True(t, shellRan, "Bash executor should use absolute path for .sh hooks") }) t.Run("LanguageHookPrepareFailure", func(t *testing.T) { @@ -743,7 +756,7 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { require.NotEmpty(t, capturedEnv) // The environment variables from the hook's env should - // be forwarded to the language executor. + // be forwarded to the executor. envMap := envSliceToMap(capturedEnv) require.Equal(t, "my_value", envMap["MY_VAR"]) require.Equal(t, "other_value", envMap["OTHER_VAR"]) diff --git a/cli/azd/pkg/ext/models.go b/cli/azd/pkg/ext/models.go index c581fda249a..94709d1052d 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -22,20 +22,7 @@ type HookType string // 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 ( - ShellTypeBash ShellType = "sh" - ShellTypePowershell ShellType = "pwsh" - ScriptTypeUnknown ShellType = "" - ScriptLocationInline ScriptLocation = "inline" - ScriptLocationPath ScriptLocation = "path" - ScriptLocationUnknown ScriptLocation = "" // Executes pre hooks HookTypePre HookType = "pre" // Execute post hooks @@ -46,23 +33,26 @@ const ( ) var ( - // ErrScriptTypeUnknown indicates the shell type could not be inferred from - // the script path and was not explicitly configured. + // ErrScriptTypeUnknown indicates the language could not be inferred + // from the script path and was not explicitly configured. ErrScriptTypeUnknown error = errors.New( - "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", + "unable to determine hook language. " + + "Ensure 'shell' or 'language' is set in your hook configuration, " + + "or use a file with a recognized extension " + + "(.sh, .ps1, .py, .js, .ts, .cs)", ) // 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 indicates the script file has an extension + // that is not recognized 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)", + "script type is not valid. " + + "Supported extensions: .sh, .ps1, .py, .js, .ts, .cs. " + + "Alternatively, set 'language' (e.g. language: python) " + + "or 'shell' (e.g. shell: sh)", ) ) @@ -71,27 +61,27 @@ type InvokeFn func() error // HookConfig defines the configuration for a single hook in azure.yaml. // Hooks are lifecycle scripts that run before or after azd commands. -// They may be shell scripts (sh/pwsh) executed via the shell runner, -// or programming-language scripts (Python, JS, TS, DotNet) executed -// via the [tools.HookExecutor] pipeline. +// Every hook is executed through a [tools.HookExecutor] resolved via IoC +// based on the hook's Language — including Bash, PowerShell, Python, +// and future executor types (JS, TS, DotNet). type HookConfig struct { - // The location of the script hook (file path or inline) - location ScriptLocation - // When location is `path` a file path must be specified relative to the project or service + // When set, contains the resolved file path relative to the project or service path string // Stores a value whether or not this hook config has been previously validated validated bool // Stores the working directory set for this hook config cwd string - // When location is `inline` a script must be defined inline + // When set, contains the inline script content to execute script string // Indicates if the shell was automatically detected based on OS (used for warnings) usingDefaultShell bool // Internal name of the hook running for a given command Name string `yaml:",omitempty"` - // The type of script hook (bash or powershell) - Shell ShellType `yaml:"shell,omitempty"` + // The type of script hook (bash or powershell). + // Retained as a string for backwards-compatible YAML + // deserialization. After validate(), only Language is used. + Shell string `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 @@ -122,10 +112,17 @@ type HookConfig struct { } // 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. +// the script location (inline vs. file path) and ensures that Language +// is always resolved to a concrete [language.ScriptLanguage] value. +// +// Language resolution priority: +// 1. Explicit Language field +// 2. Explicit Shell field (mapped to Language) +// 3. File extension via [language.InferLanguageFromPath] +// 4. OS default (Bash on Unix, PowerShell on Windows) for inline scripts +// +// After a successful call, Language is the single source of truth for +// executor selection and the hook is ready for execution. func (hc *HookConfig) validate() error { if hc.validated { return nil @@ -135,7 +132,9 @@ func (hc *HookConfig) validate() error { return ErrRunRequired } - relativeCheckPath := strings.ReplaceAll(hc.Run, "/", string(os.PathSeparator)) + relativeCheckPath := strings.ReplaceAll( + hc.Run, "/", string(os.PathSeparator), + ) fullCheckPath := relativeCheckPath if hc.cwd != "" { fullCheckPath = filepath.Join(hc.cwd, hc.Run) @@ -143,85 +142,69 @@ func (hc *HookConfig) validate() error { stats, err := os.Stat(fullCheckPath) if err == nil && !stats.IsDir() { - hc.location = ScriptLocationPath hc.path = relativeCheckPath } else { - hc.location = ScriptLocationInline hc.script = hc.Run } // Language resolution — priority: // 1. explicit Language 2. explicit Shell 3. file extension + // 4. OS default for inline scripts if hc.Language == language.ScriptLanguageUnknown { switch { - case hc.Shell != ScriptTypeUnknown: + case hc.Shell != "": hc.Language = shellToLanguage(hc.Shell) - case hc.location == ScriptLocationPath: - hc.Language = language.InferLanguageFromPath(hc.Run) + case hc.path != "": + hc.Language = language.InferLanguageFromPath( + hc.Run, + ) } } - // Reject inline scripts for non-shell (language) hooks. - if hc.location == ScriptLocationInline && hc.IsLanguageHook() { + // Reject inline scripts for non-shell executors. Only Bash and + // PowerShell executors support inline script content; other + // executors require a file on disk. + if hc.script != "" && + hc.Language != language.ScriptLanguageUnknown && + !hc.Language.IsShellLanguage() { return fmt.Errorf( "inline scripts are not supported for %s hooks. "+ - "Write your script to a file and set 'run' to the file path "+ + "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() { + // Non-shell executors handle their own runtime and dependency + // setup; no shell type resolution or temp script is needed. + if hc.Language != language.ScriptLanguageUnknown && + !hc.Language.IsShellLanguage() { // Auto-infer Dir from the script's directory when not // explicitly set by the user. - if hc.Dir == "" && hc.location == ScriptLocationPath { + if hc.Dir == "" && hc.path != "" { hc.Dir = filepath.Dir(hc.path) } hc.validated = true return nil } - // If Language resolved to a shell variant but Shell is unset, - // derive Shell so the existing shell execution path works. - if hc.Shell == ScriptTypeUnknown { - switch hc.Language { - case language.ScriptLanguageBash: - hc.Shell = ShellTypeBash - case language.ScriptLanguagePowerShell: - hc.Shell = ShellTypePowershell - } - } - - // --- existing shell behavior (unchanged) --- - - // If shell is not specified and it's an inline script, use OS default - if hc.Shell == ScriptTypeUnknown && hc.path == "" { - hc.Shell = getDefaultShellForOS() + // For inline scripts with no resolved language, default to the + // OS-appropriate shell language. + if hc.Language == language.ScriptLanguageUnknown && + hc.script != "" { + hc.Language = defaultLanguageForOS() hc.usingDefaultShell = true } - if hc.location == ScriptLocationUnknown { - if hc.path != "" { - hc.location = ScriptLocationPath - } else if hc.script != "" { - hc.location = ScriptLocationInline - } - } - - if hc.location != ScriptLocationInline && hc.Shell == ScriptTypeUnknown { - scriptType, err := inferScriptTypeFromFilePath(hc.path) - if err != nil { - return err - } - - hc.Shell = scriptType - } - - // Backfill Language from resolved Shell for shell-based hooks. + // For file-based hooks with an unrecognized extension, error. if hc.Language == language.ScriptLanguageUnknown { - hc.Language = shellToLanguage(hc.Shell) + return fmt.Errorf( + "script with file extension '%s' is not valid. "+ + "%w.", + filepath.Ext(hc.path), + ErrUnsupportedScriptType, + ) } hc.validated = true @@ -229,16 +212,22 @@ func (hc *HookConfig) validate() error { return nil } -// IsPowerShellHook determines if a hook configuration uses PowerShell +// IsPowerShellHook determines if a hook configuration uses PowerShell. +// It checks the resolved Language first, then falls back to the raw +// Shell field and file extension for hooks that have not yet been +// validated. func (hc *HookConfig) IsPowerShellHook() bool { - // Check if shell is explicitly set to pwsh - if hc.Shell == ShellTypePowershell { + if hc.Language == language.ScriptLanguagePowerShell { + return true + } + + // Check raw Shell field (pre-validation). + if hc.Shell == "pwsh" { return true } - // Check if shell is unknown but the hook file has .ps1 extension - if hc.Shell == ScriptTypeUnknown && hc.Run != "" { - // For file-based hooks, check the extension + // Check file extension for unvalidated hooks. + if hc.Run != "" { if strings.HasSuffix(strings.ToLower(hc.Run), ".ps1") { return true } @@ -247,7 +236,8 @@ func (hc *HookConfig) IsPowerShellHook() bool { // Check OS-specific hook configurations if runtime.GOOS == "windows" && hc.Windows != nil { return hc.Windows.IsPowerShellHook() - } else if (runtime.GOOS == "linux" || runtime.GOOS == "darwin") && hc.Posix != nil { + } else if (runtime.GOOS == "linux" || + runtime.GOOS == "darwin") && hc.Posix != nil { return hc.Posix.IsPowerShellHook() } @@ -260,22 +250,6 @@ func (hc *HookConfig) IsUsingDefaultShell() bool { return hc.usingDefaultShell } -// IsLanguageHook returns true when this hook targets a programming -// language (Python, JavaScript, TypeScript, or DotNet) rather than a -// shell (Bash or PowerShell). Language hooks are executed through the -// [tools.HookExecutor] pipeline instead of the shell runner. -func (hc *HookConfig) IsLanguageHook() bool { - switch hc.Language { - case language.ScriptLanguagePython, - language.ScriptLanguageJavaScript, - language.ScriptLanguageTypeScript, - language.ScriptLanguageDotNet: - return true - default: - return false - } -} - // InferHookType extracts the hook timing prefix ("pre" or "post") // from a hook name and returns the remaining command name. For // example, "preprovision" → (HookTypePre, "provision"). @@ -292,43 +266,25 @@ func InferHookType(name string) (HookType, string) { return HookTypeNone, name } -// getDefaultShellForOS returns the default shell type based on the operating system -func getDefaultShellForOS() ShellType { +// defaultLanguageForOS returns the default shell language for the +// current operating system: PowerShell on Windows, Bash elsewhere. +func defaultLanguageForOS() language.ScriptLanguage { if runtime.GOOS == "windows" { - return ShellTypePowershell + return language.ScriptLanguagePowerShell } - return ShellTypeBash + return language.ScriptLanguageBash } -// shellToLanguage maps a [ShellType] to the corresponding -// [language.ScriptLanguage]. Returns [language.ScriptLanguageUnknown] -// for unrecognized shell types. -func shellToLanguage(shell ShellType) language.ScriptLanguage { +// shellToLanguage maps a raw shell string (from the YAML "shell" +// field) to the corresponding [language.ScriptLanguage]. Returns +// [language.ScriptLanguageUnknown] for unrecognized values. +func shellToLanguage(shell string) language.ScriptLanguage { switch shell { - case ShellTypeBash: + case "sh": return language.ScriptLanguageBash - case ShellTypePowershell: + case "pwsh": 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 { - case ".sh": - return ShellTypeBash, nil - case ".ps1": - return ShellTypePowershell, nil - default: - return "", fmt.Errorf( - "script with file extension '%s' is not valid. %w.", - fileExtension, - ErrUnsupportedScriptType, - ) - } -} diff --git a/cli/azd/pkg/ext/models_test.go b/cli/azd/pkg/ext/models_test.go index 2fd591fc048..1e0b2f42386 100644 --- a/cli/azd/pkg/ext/models_test.go +++ b/cli/azd/pkg/ext/models_test.go @@ -92,7 +92,7 @@ func TestHookConfig_LanguageField(t *testing.T) { func TestHookConfig_LanguageRoundTrip(t *testing.T) { original := HookConfig{ Run: "hooks/deploy.py", - Shell: ShellTypeBash, + Shell: "sh", Language: language.ScriptLanguagePython, Dir: "hooks", } @@ -138,7 +138,7 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { config HookConfig createFile string // relative path to create under cwd expectedLanguage language.ScriptLanguage - isLanguageHook bool + isShellLanguage bool expectError string }{ { @@ -150,7 +150,7 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { }, createFile: "script.py", expectedLanguage: language.ScriptLanguagePython, - isLanguageHook: true, + isShellLanguage: false, }, { name: "ExplicitLanguageOverridesExtension", @@ -161,17 +161,17 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { }, createFile: "script.js", expectedLanguage: language.ScriptLanguagePython, - isLanguageHook: true, + isShellLanguage: false, }, { name: "ShellBashMapsToLanguage", config: HookConfig{ Name: "test", - Shell: ShellTypeBash, + Shell: "sh", Run: "echo hello", }, expectedLanguage: language.ScriptLanguageBash, - isLanguageHook: false, + isShellLanguage: true, }, { name: "InferPythonFromExtension", @@ -181,7 +181,7 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { }, createFile: "hooks/seed.py", expectedLanguage: language.ScriptLanguagePython, - isLanguageHook: true, + isShellLanguage: false, }, { name: "InferJavaScriptFromExtension", @@ -191,7 +191,7 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { }, createFile: "hooks/setup.js", expectedLanguage: language.ScriptLanguageJavaScript, - isLanguageHook: true, + isShellLanguage: false, }, { name: "InferTypeScriptFromExtension", @@ -201,7 +201,7 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { }, createFile: "hooks/test.ts", expectedLanguage: language.ScriptLanguageTypeScript, - isLanguageHook: true, + isShellLanguage: false, }, { name: "InferDotNetFromExtension", @@ -211,7 +211,7 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { }, createFile: "hooks/run.cs", expectedLanguage: language.ScriptLanguageDotNet, - isLanguageHook: true, + isShellLanguage: false, }, { name: "InferBashFromExtension", @@ -221,7 +221,7 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { }, createFile: "hooks/deploy.sh", expectedLanguage: language.ScriptLanguageBash, - isLanguageHook: false, + isShellLanguage: true, }, { name: "InferPowerShellFromExtension", @@ -231,7 +231,7 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { }, createFile: "hooks/deploy.ps1", expectedLanguage: language.ScriptLanguagePowerShell, - isLanguageHook: false, + isShellLanguage: true, }, { name: "InlineScriptDefaultsToOSShell", @@ -239,10 +239,8 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { Name: "test", Run: "echo hello", }, - expectedLanguage: shellToLanguage( - getDefaultShellForOS(), - ), - isLanguageHook: false, + expectedLanguage: defaultLanguageForOS(), + isShellLanguage: true, }, { name: "InlineScriptWithLanguagePythonErrors", @@ -258,11 +256,11 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { name: "InlineScriptWithShellBashIsOK", config: HookConfig{ Name: "test", - Shell: ShellTypeBash, + Shell: "sh", Run: "echo hello", }, expectedLanguage: language.ScriptLanguageBash, - isLanguageHook: false, + isShellLanguage: true, }, } @@ -293,7 +291,8 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { t, tt.expectedLanguage, config.Language, ) require.Equal( - t, tt.isLanguageHook, config.IsLanguageHook(), + t, tt.isShellLanguage, + config.Language.IsShellLanguage(), ) }) } @@ -347,7 +346,7 @@ func TestHookConfig_ValidateDirInference(t *testing.T) { name: "ShellHookDirUnchanged", config: HookConfig{ Name: "test", - Shell: ShellTypeBash, + Shell: "sh", Run: filepath.Join("hooks", "setup.sh"), }, createFile: filepath.Join("hooks", "setup.sh"), @@ -357,7 +356,7 @@ func TestHookConfig_ValidateDirInference(t *testing.T) { name: "InlineScriptDirUnchanged", config: HookConfig{ Name: "test", - Shell: ShellTypeBash, + Shell: "sh", Run: "echo hello", }, expectedDir: "", @@ -385,25 +384,27 @@ func TestHookConfig_ValidateDirInference(t *testing.T) { } } -func TestHookConfig_IsLanguageHook(t *testing.T) { +func TestScriptLanguage_IsShellLanguage(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}, + {"Python", language.ScriptLanguagePython, false}, + {"JavaScript", language.ScriptLanguageJavaScript, false}, + {"TypeScript", language.ScriptLanguageTypeScript, false}, + {"DotNet", language.ScriptLanguageDotNet, false}, + {"Bash", language.ScriptLanguageBash, true}, + {"PowerShell", language.ScriptLanguagePowerShell, true}, {"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()) + require.Equal( + t, tt.expected, + tt.lang.IsShellLanguage(), + ) }) } } diff --git a/cli/azd/pkg/ext/python_hooks_e2e_test.go b/cli/azd/pkg/ext/python_hooks_e2e_test.go index aca1c183ddb..73191fa91fe 100644 --- a/cli/azd/pkg/ext/python_hooks_e2e_test.go +++ b/cli/azd/pkg/ext/python_hooks_e2e_test.go @@ -158,12 +158,12 @@ func TestPythonHook_AutoDetectFromExtension(t *testing.T) { "auto-detected Python hook should execute the .py script", ) - // Verify the config was resolved as a language hook. + // Verify the config was resolved as a non-shell language hook. hookCfg := hooksMap["predeploy"][0] assert.Equal( t, language.ScriptLanguagePython, hookCfg.Language, ) - assert.True(t, hookCfg.IsLanguageHook()) + assert.False(t, hookCfg.Language.IsShellLanguage()) } // TestPythonHook_ExplicitLanguage verifies that language: python @@ -489,7 +489,7 @@ func TestPythonHook_ContinueOnError(t *testing.T) { // TestPythonHook_ProjectLevel verifies a Python hook registered // at the project level (pre) executes through the -// language executor pipeline. +// hook executor pipeline. func TestPythonHook_ProjectLevel(t *testing.T) { scriptRel := filepath.Join("hooks", "preprovision.py") cwd := newPythonTestFixture(t, scriptRel, false) @@ -534,7 +534,7 @@ func TestPythonHook_ProjectLevel(t *testing.T) { // 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. +// the hook 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") @@ -588,8 +588,8 @@ func TestPythonHook_ServiceLevel(t *testing.T) { assert.Equal(t, expectedCwd, capturedCwd) } -// TestPythonHook_ShellHookUnaffected verifies that a shell (.sh) -// hook runs through the shell script executor even when Python +// TestPythonHook_ShellHookUnaffected verifies that a Bash (.sh) +// hook runs through the Bash executor even when Python // hooks are present in the same configuration. func TestPythonHook_ShellHookUnaffected(t *testing.T) { cwd := t.TempDir() @@ -614,7 +614,7 @@ func TestPythonHook_ShellHookUnaffected(t *testing.T) { hooksMap := map[string][]*HookConfig{ "prebuild": {{ Name: "prebuild", - Shell: ShellTypeBash, + Shell: "sh", Run: filepath.Join( "hooks", "prebuild.sh", ), @@ -643,7 +643,7 @@ func TestPythonHook_ShellHookUnaffected(t *testing.T) { args exec.RunArgs, ) (exec.RunResult, error) { shellRan = true - // Shell hooks pass the script as the first arg. + // Bash hooks pass the script as the first arg. // The shell executor may use forward slashes, so // compare with forward slashes for portability. require.Contains( @@ -927,7 +927,7 @@ func TestPythonHook_ExplicitDirOverridesCwd(t *testing.T) { // TestPythonHook_InlineScriptRejected verifies that inline // Python scripts (no file path) are rejected with a clear error -// since language hooks require file-based scripts. +// since non-shell hooks require file-based scripts. func TestPythonHook_InlineScriptRejected(t *testing.T) { cwd := t.TempDir() ostest.Chdir(t, cwd) diff --git a/cli/azd/pkg/project/project_config_test.go b/cli/azd/pkg/project/project_config_test.go index dc49627d063..25e2fdc0ab7 100644 --- a/cli/azd/pkg/project/project_config_test.go +++ b/cli/azd/pkg/project/project_config_test.go @@ -476,7 +476,7 @@ func Test_Hooks_Config_Yaml_Marshalling(t *testing.T) { Hooks: HooksConfig{ "postprovision": { { - Shell: ext.ShellTypeBash, + Shell: "sh", Run: "scripts/postprovision.sh", }, }, @@ -489,7 +489,7 @@ func Test_Hooks_Config_Yaml_Marshalling(t *testing.T) { Hooks: HooksConfig{ "postprovision": { { - Shell: ext.ShellTypeBash, + Shell: "sh", Run: "scripts/postprovision.sh", }, }, @@ -514,11 +514,11 @@ func Test_Hooks_Config_Yaml_Marshalling(t *testing.T) { Hooks: map[string][]*ext.HookConfig{ "postprovision": { { - Shell: ext.ShellTypeBash, + Shell: "sh", Run: "scripts/postprovision1.sh", }, { - Shell: ext.ShellTypeBash, + Shell: "sh", Run: "scripts/postprovision2.sh", }, }, @@ -531,11 +531,11 @@ func Test_Hooks_Config_Yaml_Marshalling(t *testing.T) { Hooks: HooksConfig{ "postprovision": { { - Shell: ext.ShellTypeBash, + Shell: "sh", Run: "scripts/postprovision1.sh", }, { - Shell: ext.ShellTypeBash, + Shell: "sh", Run: "scripts/postprovision2.sh", }, }, diff --git a/cli/azd/pkg/project/project_test.go b/cli/azd/pkg/project/project_test.go index 10c829ee5d2..499f6e9181d 100644 --- a/cli/azd/pkg/project/project_test.go +++ b/cli/azd/pkg/project/project_test.go @@ -15,7 +15,6 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azure" "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/environment" - "github.com/azure/azure-dev/cli/azd/pkg/ext" "github.com/azure/azure-dev/cli/azd/pkg/infra" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/osutil" @@ -390,7 +389,7 @@ postbuild: expectedHooks := HooksConfig{ "prebuild": {{ Name: "", - Shell: ext.ShellTypeBash, + Shell: "sh", Run: "./pre-build.sh", ContinueOnError: false, Interactive: false, @@ -399,7 +398,7 @@ postbuild: }}, "postbuild": {{ Name: "", - Shell: ext.ShellTypePowershell, + Shell: "pwsh", Run: "./post-build.ps1", ContinueOnError: false, Interactive: false, @@ -419,7 +418,7 @@ postbuild: Services: map[string]*ServiceConfig{}, Hooks: HooksConfig{ "prebuild": {{ - Shell: ext.ShellTypeBash, + Shell: "sh", Run: "./pre-build.sh", }}, }, @@ -455,7 +454,7 @@ postbuild: expectedHooks := HooksConfig{ "prebuild": {{ Name: "", - Shell: ext.ShellTypeBash, + Shell: "sh", Run: "./pre-build.sh", ContinueOnError: false, Interactive: false, @@ -463,7 +462,7 @@ postbuild: Posix: nil, }, { Name: "", - Shell: ext.ShellTypeBash, + Shell: "sh", Run: "./pre-build-external.sh", ContinueOnError: false, Interactive: false, @@ -472,7 +471,7 @@ postbuild: }}, "postbuild": {{ Name: "", - Shell: ext.ShellTypePowershell, + Shell: "pwsh", Run: "./post-build.ps1", ContinueOnError: false, Interactive: false, diff --git a/cli/azd/pkg/tools/language/executor.go b/cli/azd/pkg/tools/language/executor.go index 46faa2070e4..4e94548609a 100644 --- a/cli/azd/pkg/tools/language/executor.go +++ b/cli/azd/pkg/tools/language/executor.go @@ -34,6 +34,16 @@ const ( ScriptLanguageDotNet ScriptLanguage = "dotnet" ) +// IsShellLanguage reports whether lang is one of the built-in shell +// languages (Bash or PowerShell). Shell hooks support inline scripts +// and are executed directly by the OS shell. Non-shell languages +// (Python, JS, TS, DotNet) require a file on disk and are executed +// through dedicated [tools.HookExecutor] implementations. +func (lang ScriptLanguage) IsShellLanguage() bool { + return lang == ScriptLanguageBash || + lang == ScriptLanguagePowerShell +} + // InferLanguageFromPath determines the [ScriptLanguage] from the // file extension of the given path. Extension matching is // case-insensitive. The following extensions are recognized: diff --git a/cli/azd/pkg/tools/script.go b/cli/azd/pkg/tools/script.go index bd3d073372a..d2324b2e54f 100644 --- a/cli/azd/pkg/tools/script.go +++ b/cli/azd/pkg/tools/script.go @@ -23,7 +23,7 @@ type ExecutionContext struct { EnvVars []string // BoundaryDir is the project or service root directory. - // Language executors walk upward from the script to BoundaryDir + // Executors may walk upward from the script to BoundaryDir // to discover dependency files (requirements.txt, package.json). BoundaryDir string From 20217691add10177864cb198f86f81626802120d Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Mon, 6 Apr 2026 12:51:20 -0700 Subject: [PATCH 12/25] =?UTF-8?q?fix:=20address=20PR=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20security,=20bugs,=20and=20code=20quality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate hook dir is contained within project boundary (path traversal) - Remove ErrorWithSuggestion wrapping on Prepare errors so YAML suggestion rules can match (venv, pip, runtime messages) - Verify venv path is a directory before skipping creation - Use pythonCli.InstallUrl() directly instead of extractURL parsing - Remove accidentally committed Go coverage file (cli/azd/cover) - Extract shared registerHookExecutors test helper to test/mocks/mocktools/ - Add cover to .gitignore Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/.gitignore | 1 + cli/azd/cmd/hooks_test.go | 21 ++-------- cli/azd/cmd/middleware/hooks_test.go | 21 ++-------- cli/azd/cover | 32 --------------- cli/azd/pkg/ext/hooks_manager.go | 17 ++------ cli/azd/pkg/ext/hooks_runner.go | 39 +++++++++++-------- cli/azd/pkg/ext/hooks_runner_test.go | 25 ++---------- cli/azd/pkg/tools/language/python_executor.go | 8 +++- .../test/mocks/mocktools/hook_executors.go | 28 +++++++++++++ 9 files changed, 72 insertions(+), 120 deletions(-) delete mode 100644 cli/azd/cover create mode 100644 cli/azd/test/mocks/mocktools/hook_executors.go diff --git a/cli/azd/.gitignore b/cli/azd/.gitignore index 068fcd4ac12..95663e2e346 100644 --- a/cli/azd/.gitignore +++ b/cli/azd/.gitignore @@ -10,6 +10,7 @@ azd.sln **/target # Coverage artifacts (generated by mage coverage:* targets) +cover cover-local.out cover-ci-combined.out coverage.html diff --git a/cli/azd/cmd/hooks_test.go b/cli/azd/cmd/hooks_test.go index f84eb2ea7c6..9acd4cda956 100644 --- a/cli/azd/cmd/hooks_test.go +++ b/cli/azd/cmd/hooks_test.go @@ -16,32 +16,17 @@ 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/azure/azure-dev/cli/azd/test/mocks/mocktools" "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. +// registerHookExecutors delegates to the shared test helper in test/mocks/mocktools. 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, - ) + mocktools.RegisterHookExecutors(mockCtx) } func Test_HooksRunAction_RunsLayerHooks(t *testing.T) { diff --git a/cli/azd/cmd/middleware/hooks_test.go b/cli/azd/cmd/middleware/hooks_test.go index 6f48fc09ff0..46bc71e29f5 100644 --- a/cli/azd/cmd/middleware/hooks_test.go +++ b/cli/azd/cmd/middleware/hooks_test.go @@ -20,12 +20,9 @@ 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/mocks/mocktools" "github.com/azure/azure-dev/cli/azd/test/ostest" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -1051,18 +1048,8 @@ 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. + +// registerHookExecutors delegates to the shared test helper in test/mocks/mocktools. 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, - ) + mocktools.RegisterHookExecutors(mockCtx) } diff --git a/cli/azd/cover b/cli/azd/cover deleted file mode 100644 index a61bb9d87e7..00000000000 --- a/cli/azd/cover +++ /dev/null @@ -1,32 +0,0 @@ -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/pkg/ext/hooks_manager.go b/cli/azd/pkg/ext/hooks_manager.go index 6c6a657f9bc..d67b0b9af5f 100644 --- a/cli/azd/pkg/ext/hooks_manager.go +++ b/cli/azd/pkg/ext/hooks_manager.go @@ -137,6 +137,7 @@ type HookValidationResult struct { type HookWarning struct { Message string Suggestion string + URL string } // ValidateHooks validates hook configurations and returns any warnings @@ -334,6 +335,7 @@ func (h *HooksManager) validateRuntimes( "Python Downloads", ), ), + URL: pythonCli.InstallUrl(), }) } } @@ -364,7 +366,7 @@ func (h *HooksManager) ValidateRuntimesErr( links := make([]errorhandler.ErrorLink, 0, len(warnings)) for _, w := range warnings { links = append(links, errorhandler.ErrorLink{ - URL: extractURL(w.Suggestion), + URL: w.URL, Title: w.Message, }) } @@ -379,16 +381,3 @@ func (h *HooksManager) ValidateRuntimesErr( 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_runner.go b/cli/azd/pkg/ext/hooks_runner.go index 906e778540c..5fa9a05c98c 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -17,6 +17,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/ioc" "github.com/azure/azure-dev/cli/azd/pkg/keyvault" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/tools" ) @@ -174,6 +175,26 @@ func (h *HooksRunner) execHook( ) } + // Validate that cwd is contained within boundaryDir to + // prevent path traversal via ".." in the dir config field. + absDir, err := filepath.Abs(cwd) + if err != nil { + return fmt.Errorf("resolving hook directory: %w", err) + } + absBoundary, err := filepath.Abs(boundaryDir) + if err != nil { + return fmt.Errorf( + "resolving boundary directory: %w", err, + ) + } + if !osutil.IsPathContained(absBoundary, absDir) { + return fmt.Errorf( + "hook directory %q escapes project root %q", + cwd, boundaryDir, + ) + } + cwd = absDir + envVars := hookEnv.Environ() // Build execution context. @@ -225,23 +246,7 @@ func (h *HooksRunner) execHook( ) if err := executor.Prepare(ctx, scriptPath, execCtx); err != nil { - return &errorhandler.ErrorWithSuggestion{ - Err: fmt.Errorf( - "preparing %s hook '%s': %w", - hookConfig.Language, - hookConfig.Name, - err, - ), - Message: fmt.Sprintf( - "Failed to prepare %s hook '%s'.", - hookConfig.Language, - hookConfig.Name, - ), - Suggestion: fmt.Sprintf( - "Ensure the required runtime for '%s' is installed.", - hookConfig.Language, - ), - } + return fmt.Errorf("preparing hook '%s': %w", hookConfig.Name, err) } // Cleanup temp resources created during Prepare (e.g. inline diff --git a/cli/azd/pkg/ext/hooks_runner_test.go b/cli/azd/pkg/ext/hooks_runner_test.go index 2bab861d959..bead1849417 100644 --- a/cli/azd/pkg/ext/hooks_runner_test.go +++ b/cli/azd/pkg/ext/hooks_runner_test.go @@ -14,34 +14,17 @@ 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/mocks/mocktools" "github.com/azure/azure-dev/cli/azd/test/ostest" "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. +// registerHookExecutors delegates to the shared test helper in test/mocks/mocktools. 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, - ) + mocktools.RegisterHookExecutors(mockCtx) } func Test_Hooks_Execute(t *testing.T) { @@ -622,7 +605,7 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { ) require.Error(t, err) - require.Contains(t, err.Error(), "preparing python hook") + require.Contains(t, err.Error(), "preparing hook") }) t.Run("LanguageHookExecuteFailure", func(t *testing.T) { diff --git a/cli/azd/pkg/tools/language/python_executor.go b/cli/azd/pkg/tools/language/python_executor.go index 768d5d30a37..af29c570269 100644 --- a/cli/azd/pkg/tools/language/python_executor.go +++ b/cli/azd/pkg/tools/language/python_executor.go @@ -136,8 +136,14 @@ func (e *pythonExecutor) ensureVenv( projectDir, venvName, venvPath string, envVars []string, ) error { - _, statErr := os.Stat(venvPath) + info, statErr := os.Stat(venvPath) if statErr == nil { + if !info.IsDir() { + return fmt.Errorf( + "venv path %q exists but is not a directory", + venvPath, + ) + } // Venv directory already exists — skip creation. return nil } diff --git a/cli/azd/test/mocks/mocktools/hook_executors.go b/cli/azd/test/mocks/mocktools/hook_executors.go new file mode 100644 index 00000000000..cfbd55f6ef5 --- /dev/null +++ b/cli/azd/test/mocks/mocktools/hook_executors.go @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package mocktools + +import ( + "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" +) + +// 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, + ) +} From 2d8adc3b1444dcc2ce3edad47ad533ce5f7a6954 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 7 Apr 2026 10:30:27 -0700 Subject: [PATCH 13/25] fix: address PR review feedback from jongio Code fixes: - Guard shell hook cwd to preserve project-root behavior (breaking change fix) - Filter Python executor project discovery by language (skip non-Python contexts) - Restore PowerShell PS5 fallback diagnostic on script failure - Add Links field to ErrorWithSuggestion for unsupported languages - Detect existing virtual environments (VIRTUAL_ENV, .venv, venv dirs) - Replace hardcoded "sh"/"pwsh" test strings with language constants Documentation: - Align dir field terminology with azd cwd convention - Update dir override example to show genuinely different paths - Lead with auto-inference in dir documentation - Add "unvalidated" to cspell overrides for models.go Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/.vscode/cspell.yaml | 727 +++++++++--------- cli/azd/cmd/hooks_test.go | 10 +- cli/azd/cmd/middleware/hooks_test.go | 34 +- cli/azd/docs/language-hooks.md | 31 +- cli/azd/pkg/ext/hooks_config_test.go | 13 +- cli/azd/pkg/ext/hooks_manager_test.go | 10 +- cli/azd/pkg/ext/hooks_runner.go | 12 +- cli/azd/pkg/ext/hooks_runner_test.go | 25 +- cli/azd/pkg/ext/models_test.go | 10 +- cli/azd/pkg/ext/python_hooks_e2e_test.go | 2 +- cli/azd/pkg/tools/language/python_executor.go | 135 +++- .../tools/language/python_executor_test.go | 265 +++++++ cli/azd/pkg/tools/powershell/powershell.go | 16 +- 13 files changed, 854 insertions(+), 436 deletions(-) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 6c32f308483..5d1c0f6fbcd 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -1,360 +1,367 @@ -import: ../../../.vscode/cspell.global.yaml -words: - - agentcopilot - - agentdetect - - Authenticode - - gofmt - - golangci - - toplevel - - azcloud - - azdext - - azurefd - - azcontainerregistry - - CGNAT - - backoff - - Canonicalize - - Chans - - chinacloudapi - - cmds - - Codespace - - Codespaces - - codesign - - cooldown - - customtype - - devcontainers - - errgroup - - errorhandler - - extendee - - eiannone - # CDN host name - - gfgac2cmf7b8cuay - - Getenv - - goversioninfo - - OPENCODE - - opencode - - grpcbroker - - msiexec - - nosec - - oneof - - idxs - # Looks like the protogen has a spelling error for panics - - pancis - - Paren - - OIDCREQUESTURI - - pkgux - - ppid - - PPID - - proto - - protobuf - - protoc - - protoimpl - - protojson - - protoreflect - - SNAPPROCESS - - structpb - - syncmap - - syscall - - Retryable - - runcontext - - surveyterm - - Toolhelp - - unmarshals - - unmarshaling - - unsetting - - usgovcloudapi - - worktrees - - jdbc - - springframework - - santhosh - - tekuri - - jsonschema - - rustc - - figspec - - finetune - - gitdir - - bubbletea - - lipgloss - - gopxl - - subcmd - - genproto - - errdetails - - slogger - - SSRF - - ssrf - - Teredo - - allowlist - - blocklist - - metacharacter - - metacharacters - - yarnpkg - - azconfig - - hostnames - - managedhsm - - microsoftazure - - seekable - - seekability - - APFS - - NTFS - - mcpgo - - cpus - - unsanitized - - PATHEXT - - mintty - - dockerenv - - dedup - - exfiltration - - Fprintf - - gocritic - - gradlew - - IMDS - - managedhsm - - microsoftazure - - myhost - - newtag - - preconfigured - - Println - - sctx - - secretname - - secretversion - - TTLs - - vaultname -languageSettings: - - languageId: go - ignoreRegExpList: - - /mock.*/ -dictionaryDefinitions: - - name: azdProjectDictionary - path: ./cspell-azd-dictionary.txt - description: Custom Workspace Dictionary - addWords: true - scope: workspace -dictionaries: - - azdProjectDictionary -overrides: - - filename: cmd/mcp.go - words: - - internalcmd - - filename: cmd/extension.go - words: - - compatResult - - compatCopy - - filename: pkg/azdext/config_helper.go - words: - - myext - - filename: internal/tracing/fields/domains.go - words: - - azmk - - azurecontainerapps - - azureedge - - azurefd - - cloudapp - - mediaservices - - msecnd - - filename: docs/docgen.go - words: - - alexwolf - - alexwolfmsft - - pname - - puichan - - devx - - azdevcli - - azdeveloper - - hannahhunter - - hhunter - - filename: pkg/tools/python/python.go - words: - - venv - - filename: pkg/spin/run.go - words: - - errored - - filename: pkg/project/project_utils.go - words: - - __pycache__ - - Venv - - filename: pkg/project/project_test.go - words: - - Aapi - - Aweb - - Aworker - - azdtempl - - filename: pkg/executil/executil_windows.go - words: - - hndl - - JOBOBJECT - - filename: pkg/environment/azdcontext/azdcontext.go - words: - - ents - - unmarshalled - - filename: pkg/exec/util_test.go - words: - - keychain - - crusername - - azurecr - - filename: pkg/tools/dotnet/dotnet.go - words: - - PWORD - - filename: pkg/tools/kubectl/kubectl.go - words: - - tmpl - - filename: pkg/experimentation/assignment.go - words: - - assignmentcache - - machineid - - azdversion - - filename: pkg/experimentation/client.go - words: - - variantassignment - - filename: pkg/azsdk/storage/storage_blob_client.go - words: - - azblob - - filename: pkg/project/service_target_aks.go - words: - - kustomization - - templating - - filename: pkg/project/scaffold_gen.go - words: - - GUNICORN - - MULTIWORKERS - - filename: pkg/extensions/command_resolver.go - words: - - intarray - - stringarray - - filename: CHANGELOG.md - words: - - aistudio - - apiserver - - azdevify - - Chdir - - creds - - Moby - - upsert - - filename: docs/proxy-configuration.md - words: - - Telerik - - filename: test/recording/recording.go - words: - - oidctoken - - filename: test/recording/proxy.go - words: - - WSAECONNRESET - - filename: test/functional/testdata/samples/funcapp/getting_started.md - words: - - funcignore - - filename: internal/vsrpc/handler.go - words: - - arity - - filename: test/internal/tfoidc/main.go - words: - - tfoidc - - OIDCREQUESTURI - - oidctoken - - filename: CHANGELOG.md - words: - - GHSA - - gjrc - - filename: pkg/apphost/generate.go - words: - - alue - - filename: docs/extensions/extension-framework.md - words: - - getenv - - errorf - - println - - myext - - Fatalf - - filename: pkg/project/dockerfile_builder.go - words: - - WORKDIR - - workdir - - filename: docs/style-guidelines/new-azd-command.md - words: - - pflag - - struct - - Errorf - - Sprintf - - mycommand - - omitempty - - Fprintf - - tabwriter - - azdcontext - - azapi - - eastus - - filename: cmd/cmd_help.go - words: - - cmdinternal - - filename: .github/copilot-instructions.md - words: - - golines - - technicalterm - - Errorf - - filename: internal/mcp/tools/azd_yaml_schema.go - words: - - santhosh - - tekuri - - jsonschema - - filename: internal/mcp/host.go - words: - - Streamable - - filename: cmd/completion.go - words: - - compinit - - filename: pkg/project/service_target_dotnet_containerapp.go - words: - - IMAGENAME - - filename: internal/vsrpc/server.go - words: - - CSWSH - - filename: pkg/extensions/manager.go - words: - - myext - - filename: docs/recording-functional-tests-guide.md - words: - - httptest - - Logf - - Getenv - - httptest - - filename: docs/extensions/extension-framework.md - words: - - invopop - - filename: docs/style-guidelines/azd-style-guide.md - words: - - camelCase - - checkmark - - Colorization - - gofmt - - myService - - NewMyService - - PascalCase - - SomeDependency - - testify - - userConfig - - Println - - adrz - - rvwxu - - filename: pkg/extensions/metadata.go - words: - - invopop - - filename: pkg/extensions/schema_validator.go - words: - - invopop - - jsonschemav - - filename: pkg/project/dockerfile_builder.go - words: - - WORKDIR - - workdir - - filename: pkg/azdext/scope_detector.go - words: - - fakeazure - - filename: "{pkg/azapi/permissions.go,pkg/infra/provisioning/bicep/bicep_provider.go}" - words: - - ABAC - - filename: pkg/infra/provisioning/bicep/local_preflight.go - words: - - actioned - - filename: pkg/project/project.go - words: - - copylocks -ignorePaths: - - "**/*_test.go" - - "**/mock*.go" - - "**/*.pb.go" +import: ../../../.vscode/cspell.global.yaml +words: + - agentcopilot + - agentdetect + - Authenticode + - gofmt + - golangci + - toplevel + - azcloud + - azdext + - azurefd + - azcontainerregistry + - CGNAT + - backoff + - Canonicalize + - Chans + - chinacloudapi + - cmds + - Codespace + - Codespaces + - codesign + - cooldown + - customtype + - devcontainers + - errgroup + - errorhandler + - extendee + - eiannone + # CDN host name + - gfgac2cmf7b8cuay + - Getenv + - goversioninfo + - OPENCODE + - opencode + - grpcbroker + - msiexec + - nosec + - oneof + - idxs + # Looks like the protogen has a spelling error for panics + - pancis + - Paren + - OIDCREQUESTURI + - pkgux + - ppid + - PPID + - proto + - protobuf + - protoc + - protoimpl + - protojson + - protoreflect + - SNAPPROCESS + - structpb + - syncmap + - syscall + - Retryable + - runcontext + - surveyterm + - Toolhelp + - unmarshals + - unmarshaling + - unsetting + - usgovcloudapi + - worktrees + - jdbc + - springframework + - santhosh + - tekuri + - jsonschema + - rustc + - figspec + - finetune + - gitdir + - bubbletea + - lipgloss + - gopxl + - subcmd + - genproto + - errdetails + - slogger + - SSRF + - ssrf + - Teredo + - allowlist + - blocklist + - metacharacter + - metacharacters + - yarnpkg + - azconfig + - hostnames + - managedhsm + - microsoftazure + - seekable + - seekability + - APFS + - NTFS + - mcpgo + - cpus + - unsanitized + - PATHEXT + - mintty + - dockerenv + - dedup + - exfiltration + - Fprintf + - gocritic + - gradlew + - IMDS + - managedhsm + - microsoftazure + - myhost + - newtag + - preconfigured + - Println + - sctx + - secretname + - secretversion + - TTLs + - vaultname +languageSettings: + - languageId: go + ignoreRegExpList: + - /mock.*/ +dictionaryDefinitions: + - name: azdProjectDictionary + path: ./cspell-azd-dictionary.txt + description: Custom Workspace Dictionary + addWords: true + scope: workspace +dictionaries: + - azdProjectDictionary +overrides: + - filename: cmd/mcp.go + words: + - internalcmd + - filename: cmd/extension.go + words: + - compatResult + - compatCopy + - filename: pkg/azdext/config_helper.go + words: + - myext + - filename: internal/tracing/fields/domains.go + words: + - azmk + - azurecontainerapps + - azureedge + - azurefd + - cloudapp + - mediaservices + - msecnd + - filename: docs/docgen.go + words: + - alexwolf + - alexwolfmsft + - pname + - puichan + - devx + - azdevcli + - azdeveloper + - hannahhunter + - hhunter + - filename: pkg/tools/python/python.go + words: + - venv + - filename: pkg/spin/run.go + words: + - errored + - filename: pkg/project/project_utils.go + words: + - __pycache__ + - Venv + - filename: pkg/project/project_test.go + words: + - Aapi + - Aweb + - Aworker + - azdtempl + - filename: pkg/executil/executil_windows.go + words: + - hndl + - JOBOBJECT + - filename: pkg/environment/azdcontext/azdcontext.go + words: + - ents + - unmarshalled + - filename: pkg/exec/util_test.go + words: + - keychain + - crusername + - azurecr + - filename: pkg/tools/dotnet/dotnet.go + words: + - PWORD + - filename: pkg/tools/kubectl/kubectl.go + words: + - tmpl + - filename: pkg/experimentation/assignment.go + words: + - assignmentcache + - machineid + - azdversion + - filename: pkg/experimentation/client.go + words: + - variantassignment + - filename: pkg/azsdk/storage/storage_blob_client.go + words: + - azblob + - filename: pkg/project/service_target_aks.go + words: + - kustomization + - templating + - filename: pkg/project/scaffold_gen.go + words: + - GUNICORN + - MULTIWORKERS + - filename: pkg/extensions/command_resolver.go + words: + - intarray + - stringarray + - filename: CHANGELOG.md + words: + - aistudio + - apiserver + - azdevify + - Chdir + - creds + - Moby + - upsert + - filename: docs/proxy-configuration.md + words: + - Telerik + - filename: test/recording/recording.go + words: + - oidctoken + - filename: test/recording/proxy.go + words: + - WSAECONNRESET + - filename: test/functional/testdata/samples/funcapp/getting_started.md + words: + - funcignore + - filename: internal/vsrpc/handler.go + words: + - arity + - filename: test/internal/tfoidc/main.go + words: + - tfoidc + - OIDCREQUESTURI + - oidctoken + - filename: CHANGELOG.md + words: + - GHSA + - gjrc + - filename: pkg/apphost/generate.go + words: + - alue + - filename: docs/extensions/extension-framework.md + words: + - getenv + - errorf + - println + - myext + - Fatalf + - filename: pkg/project/dockerfile_builder.go + words: + - WORKDIR + - workdir + - filename: docs/style-guidelines/new-azd-command.md + words: + - pflag + - struct + - Errorf + - Sprintf + - mycommand + - omitempty + - Fprintf + - tabwriter + - azdcontext + - azapi + - eastus + - filename: cmd/cmd_help.go + words: + - cmdinternal + - filename: .github/copilot-instructions.md + words: + - golines + - technicalterm + - Errorf + - filename: internal/mcp/tools/azd_yaml_schema.go + words: + - santhosh + - tekuri + - jsonschema + - filename: internal/mcp/host.go + words: + - Streamable + - filename: cmd/completion.go + words: + - compinit + - filename: pkg/project/service_target_dotnet_containerapp.go + words: + - IMAGENAME + - filename: internal/vsrpc/server.go + words: + - CSWSH + - filename: pkg/extensions/manager.go + words: + - myext + - filename: docs/recording-functional-tests-guide.md + words: + - httptest + - Logf + - Getenv + - httptest + - filename: docs/extensions/extension-framework.md + words: + - invopop + - filename: docs/style-guidelines/azd-style-guide.md + words: + - camelCase + - checkmark + - Colorization + - gofmt + - myService + - NewMyService + - PascalCase + - SomeDependency + - testify + - userConfig + - Println + - adrz + - rvwxu + - filename: pkg/extensions/metadata.go + words: + - invopop + - filename: pkg/extensions/schema_validator.go + words: + - invopop + - jsonschemav + - filename: pkg/project/dockerfile_builder.go + words: + - WORKDIR + - workdir + - filename: pkg/azdext/scope_detector.go + words: + - fakeazure + - filename: "{pkg/azapi/permissions.go,pkg/infra/provisioning/bicep/bicep_provider.go}" + words: + - ABAC + - filename: pkg/infra/provisioning/bicep/local_preflight.go + words: + - actioned + - filename: pkg/project/project.go + words: + - copylocks + - filename: pkg/ext/models.go + words: + - unvalidated + - filename: docs/code-coverage-guide.md + words: + - covdata + - GOWORK +ignorePaths: + - "**/*_test.go" + - "**/mock*.go" + - "**/*.pb.go" diff --git a/cli/azd/cmd/hooks_test.go b/cli/azd/cmd/hooks_test.go index 9acd4cda956..3a196d546ff 100644 --- a/cli/azd/cmd/hooks_test.go +++ b/cli/azd/cmd/hooks_test.go @@ -50,7 +50,7 @@ func Test_HooksRunAction_RunsLayerHooks(t *testing.T) { Path: "infra/core", Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "echo core", }}, }, @@ -60,7 +60,7 @@ func Test_HooksRunAction_RunsLayerHooks(t *testing.T) { Path: absoluteLayerPath, Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "echo shared", }}, }, @@ -118,7 +118,7 @@ func Test_HooksRunAction_FiltersLayerHooks(t *testing.T) { Path: "infra/core", Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "echo core", }}, }, @@ -128,7 +128,7 @@ func Test_HooksRunAction_FiltersLayerHooks(t *testing.T) { Path: "infra/shared", Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "echo shared", }}, }, @@ -188,7 +188,7 @@ func Test_HooksRunAction_SetsTelemetryTypeForLayer(t *testing.T) { Path: "infra/core", Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "echo core", }}, }, diff --git a/cli/azd/cmd/middleware/hooks_test.go b/cli/azd/cmd/middleware/hooks_test.go index 46bc71e29f5..1d2c7fab0bf 100644 --- a/cli/azd/cmd/middleware/hooks_test.go +++ b/cli/azd/cmd/middleware/hooks_test.go @@ -20,6 +20,7 @@ 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/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/mocks/mocktools" @@ -42,7 +43,7 @@ func Test_CommandHooks_Middleware_WithValidProjectAndMatchingCommand(t *testing. "precommand": { { Run: "echo 'hello'", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), }, }, }, @@ -77,7 +78,7 @@ func Test_CommandHooks_Middleware_ValidProjectWithDifferentCommand(t *testing.T) "precommand": { { Run: "echo 'hello'", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), }, }, }, @@ -139,7 +140,7 @@ func Test_CommandHooks_Middleware_PreHookWithError(t *testing.T) { "precommand": { { Run: "exit 1", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), }, }, }, @@ -177,7 +178,7 @@ func Test_CommandHooks_Middleware_PreHookWithErrorAndContinue(t *testing.T) { "precommand": { { Run: "exit 1", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), ContinueOnError: true, }, }, @@ -216,7 +217,7 @@ func Test_CommandHooks_Middleware_WithCmdAlias(t *testing.T) { "prealias": { { Run: "echo 'hello'", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), }, }, }, @@ -258,7 +259,7 @@ func Test_ServiceHooks_Registered(t *testing.T) { Hooks: map[string][]*ext.HookConfig{ "predeploy": { { - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "echo 'Hello'", }, }, @@ -535,7 +536,7 @@ func Test_PowerShellWarning_WithPowerShellHooks(t *testing.T) { "preprovision": { { Run: "Write-Host 'hello'", - Shell: "pwsh", + Shell: string(language.ScriptLanguagePowerShell), }, }, }, @@ -583,8 +584,8 @@ func Test_PowerShellWarning_WithPs1FileHook(t *testing.T) { Hooks: map[string][]*ext.HookConfig{ "preprovision": { { - Run: "script.ps1", // PowerShell file extension - Shell: "pwsh", // Explicitly specify shell to avoid detection issues + Run: "script.ps1", // PowerShell file extension + Shell: string(language.ScriptLanguagePowerShell), // Explicitly specify shell to avoid detection issues }, }, }, @@ -631,7 +632,7 @@ func Test_PowerShellWarning_WithoutPowerShellHooks(t *testing.T) { "precommand": { { Run: "echo 'hello'", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), }, }, }, @@ -710,7 +711,7 @@ func Test_CommandHooks_ChildAction_HooksStillFire(t *testing.T) { tt.hookName: { { Run: "echo 'hook running'", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), }, }, }, @@ -871,13 +872,13 @@ func Test_CommandHooks_ChildAction_SkipsValidationOnly(t *testing.T) { "preprovision": { { Run: "echo 'preprovision hook'", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), }, }, "postprovision": { { Run: "echo 'postprovision hook'", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), }, }, }, @@ -928,7 +929,7 @@ func Test_CommandHooks_ChildAction_PreHookError_StopsAction(t *testing.T) { "preprovision": { { Run: "exit 1", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), }, }, }, @@ -966,7 +967,7 @@ func Test_PowerShellWarning_WithPwshAvailable(t *testing.T) { "precommand": { { Run: "Write-Host 'hello'", - Shell: "pwsh", + Shell: string(language.ScriptLanguagePowerShell), }, }, }, @@ -1013,7 +1014,7 @@ func Test_PowerShellWarning_WithNoPowerShellInstalled(t *testing.T) { "preprovision": { { Run: "Write-Host 'hello'", - Shell: "pwsh", + Shell: string(language.ScriptLanguagePowerShell), }, }, }, @@ -1048,7 +1049,6 @@ func Test_PowerShellWarning_WithNoPowerShellInstalled(t *testing.T) { require.True(t, foundWarning, "Expected 'No PowerShell installation detected' warning to be displayed") } - // registerHookExecutors delegates to the shared test helper in test/mocks/mocktools. func registerHookExecutors(mockCtx *mocks.MockContext) { mocktools.RegisterHookExecutors(mockCtx) diff --git a/cli/azd/docs/language-hooks.md b/cli/azd/docs/language-hooks.md index 382c2cc347e..bfcb4911cb0 100644 --- a/cli/azd/docs/language-hooks.md +++ b/cli/azd/docs/language-hooks.md @@ -29,17 +29,17 @@ When omitted, the executor is **auto-detected** from the file extension of the `run` path. For example, `run: ./hooks/seed.py` automatically selects the Python executor. -### `dir` (string, optional) +### `dir` (string, optional) — working directory -Specifies the working directory for hook execution, used as the project -context for dependency installation (e.g. `pip install` from `requirements.txt`) -and builds. +The working directory (`cwd`) for 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). +**Automatically inferred** from the directory containing the script referenced +by `run`. For example, `run: hooks/preprovision/main.py` infers the working +directory as `hooks/preprovision/`. Only set `dir` as an override when the +project root differs from the script's directory (e.g. the entry point lives +in a `src/` subdirectory but `requirements.txt` is in the parent). Relative paths are resolved from the project or service root. @@ -82,18 +82,17 @@ hooks: language: python ``` -### Python hook with project directory override +### Python hook with working directory override -When the script's project root differs from the script's directory (e.g. the -entry point is in a `src/` subdirectory but dependencies are at the project -level), use `dir` to override the auto-inferred value: +When the script lives in a subdirectory but dependencies (`requirements.txt`) +are at the parent level, use `dir` to override the auto-inferred working +directory: ```yaml hooks: postprovision: - run: ./hooks/data-tool/src/main.py - language: python - dir: ./hooks/data-tool # override: project root differs from script location + run: ./tools/scripts/seed.py + dir: ./tools # override: requirements.txt is in ./tools, not ./tools/scripts ``` ### Python hook with platform overrides diff --git a/cli/azd/pkg/ext/hooks_config_test.go b/cli/azd/pkg/ext/hooks_config_test.go index 37164264ec6..c3a00331635 100644 --- a/cli/azd/pkg/ext/hooks_config_test.go +++ b/cli/azd/pkg/ext/hooks_config_test.go @@ -6,6 +6,7 @@ package ext import ( "testing" + "github.com/azure/azure-dev/cli/azd/pkg/tools/language" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" @@ -26,7 +27,7 @@ preprovision: require.Equal(t, HooksConfig{ "preprovision": { { - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "scripts/preprovision.sh", }, }, @@ -49,11 +50,11 @@ preprovision: require.Equal(t, HooksConfig{ "preprovision": { { - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "scripts/preprovision-1.sh", }, { - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "scripts/preprovision-2.sh", }, }, @@ -66,7 +67,7 @@ func TestHooksConfig_MarshalYAML(t *testing.T) { hooks := HooksConfig{ "preprovision": { { - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "scripts/preprovision.sh", }, }, @@ -86,11 +87,11 @@ preprovision: hooks := HooksConfig{ "preprovision": { { - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "scripts/preprovision-1.sh", }, { - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "scripts/preprovision-2.sh", }, }, diff --git a/cli/azd/pkg/ext/hooks_manager_test.go b/cli/azd/pkg/ext/hooks_manager_test.go index d8fca78c720..ee09ac92657 100644 --- a/cli/azd/pkg/ext/hooks_manager_test.go +++ b/cli/azd/pkg/ext/hooks_manager_test.go @@ -52,13 +52,13 @@ func Test_GetAllHookConfigs(t *testing.T) { hooksMap := map[string][]*HookConfig{ "preinit": { { - Shell: "sh", + Shell: string(language.ScriptLanguageBash), // Run is missing - this should cause an error }, }, "postinit": { { - Shell: "sh", + Shell: string(language.ScriptLanguageBash), // Run is missing - this should cause an error }, }, @@ -124,13 +124,13 @@ func Test_GetByParams(t *testing.T) { hooksMap := map[string][]*HookConfig{ "preinit": { { - Shell: "sh", + Shell: string(language.ScriptLanguageBash), // Run is missing - this should cause an error }, }, "postinit": { { - Shell: "sh", + Shell: string(language.ScriptLanguageBash), // Run is missing - this should cause an error }, }, @@ -167,7 +167,7 @@ func Test_HookConfig_DefaultShell(t *testing.T) { name: "Shell explicitly specified - should not use default", hookConfig: &HookConfig{ Name: "test", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "echo 'hello'", }, expectedLanguage: language.ScriptLanguageBash, diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index 5fa9a05c98c..94f752f65f6 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -169,7 +169,11 @@ func (h *HooksRunner) execHook( dir = filepath.Join(boundaryDir, dir) } cwd = dir - } else if hookConfig.path != "" { + } else if hookConfig.path != "" && !hookConfig.Language.IsShellLanguage() { + // Non-shell languages (Python, JS/TS, .NET) derive cwd from + // the script's directory so that project-relative tooling + // (venvs, node_modules) resolves correctly. Shell hooks + // retain the original behavior: cwd = project root. cwd = filepath.Dir( filepath.Join(boundaryDir, hookConfig.path), ) @@ -228,6 +232,12 @@ func (h *HooksRunner) execHook( hookConfig.Name, ), Suggestion: "Supported hook languages: sh, pwsh, python.", + Links: []errorhandler.ErrorLink{ + { + Title: "Hook documentation", + URL: "https://learn.microsoft.com/azure/developer/azure-developer-cli/azd-extensibility", + }, + }, } } diff --git a/cli/azd/pkg/ext/hooks_runner_test.go b/cli/azd/pkg/ext/hooks_runner_test.go index bead1849417..933e33b4bdf 100644 --- a/cli/azd/pkg/ext/hooks_runner_test.go +++ b/cli/azd/pkg/ext/hooks_runner_test.go @@ -14,6 +14,7 @@ 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/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/mocks/mocktools" @@ -43,24 +44,24 @@ func Test_Hooks_Execute(t *testing.T) { hooksMap := map[string][]*HookConfig{ "preinline": { { - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "echo 'Hello'", }, }, "precommand": { { - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "scripts/precommand.sh", }, }, "postcommand": {{ - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "scripts/postcommand.sh", }, }, "preinteractive": { { - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "scripts/preinteractive.sh", Interactive: true, }, @@ -85,7 +86,7 @@ func Test_Hooks_Execute(t *testing.T) { require.Equal(t, filepath.ToSlash( filepath.Join(scriptsDir, "precommand.sh"), ), args.Args[0]) - require.Equal(t, scriptsDir, args.Cwd) + require.Equal(t, cwd, args.Cwd) require.ElementsMatch(t, env.Environ(), args.Env) require.Equal(t, false, args.Interactive) @@ -123,7 +124,7 @@ func Test_Hooks_Execute(t *testing.T) { require.Equal(t, filepath.ToSlash( filepath.Join(scriptsDir, "postcommand.sh"), ), args.Args[0]) - require.Equal(t, scriptsDir, args.Cwd) + require.Equal(t, cwd, args.Cwd) require.ElementsMatch(t, env.Environ(), args.Env) require.Equal(t, false, args.Interactive) @@ -161,7 +162,7 @@ func Test_Hooks_Execute(t *testing.T) { require.Equal(t, filepath.ToSlash( filepath.Join(scriptsDir, "preinteractive.sh"), ), args.Args[0]) - require.Equal(t, scriptsDir, args.Cwd) + require.Equal(t, cwd, args.Cwd) require.ElementsMatch(t, env.Environ(), args.Env) require.Equal(t, true, args.Interactive) @@ -314,7 +315,7 @@ func Test_Hooks_Validation(t *testing.T) { hooksMap := map[string][]*HookConfig{ "predeploy": {{ Name: "predeploy", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "scripts/script.sh", }}, } @@ -376,7 +377,7 @@ func Test_Hooks_Validation(t *testing.T) { hooksMap := map[string][]*HookConfig{ "preinline": {{ Name: "preinline", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "echo 'Hello'", }}, } @@ -406,7 +407,7 @@ func Test_Hooks_Validation(t *testing.T) { hooksMap := map[string][]*HookConfig{ "predeploy": {{ Name: "predeploy", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), }}, } @@ -507,7 +508,7 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { "predeploy": { { Name: "predeploy", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "scripts/predeploy.sh", }, }, @@ -528,7 +529,7 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { require.Equal(t, filepath.ToSlash( filepath.Join(cwd, "scripts", "predeploy.sh"), ), args.Args[0]) - require.Equal(t, filepath.Join(cwd, "scripts"), args.Cwd) + require.Equal(t, cwd, args.Cwd) return exec.NewRunResult(0, "", ""), nil }) diff --git a/cli/azd/pkg/ext/models_test.go b/cli/azd/pkg/ext/models_test.go index 1e0b2f42386..9e7e70df9ff 100644 --- a/cli/azd/pkg/ext/models_test.go +++ b/cli/azd/pkg/ext/models_test.go @@ -92,7 +92,7 @@ func TestHookConfig_LanguageField(t *testing.T) { func TestHookConfig_LanguageRoundTrip(t *testing.T) { original := HookConfig{ Run: "hooks/deploy.py", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Language: language.ScriptLanguagePython, Dir: "hooks", } @@ -167,7 +167,7 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { name: "ShellBashMapsToLanguage", config: HookConfig{ Name: "test", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "echo hello", }, expectedLanguage: language.ScriptLanguageBash, @@ -256,7 +256,7 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { name: "InlineScriptWithShellBashIsOK", config: HookConfig{ Name: "test", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "echo hello", }, expectedLanguage: language.ScriptLanguageBash, @@ -346,7 +346,7 @@ func TestHookConfig_ValidateDirInference(t *testing.T) { name: "ShellHookDirUnchanged", config: HookConfig{ Name: "test", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: filepath.Join("hooks", "setup.sh"), }, createFile: filepath.Join("hooks", "setup.sh"), @@ -356,7 +356,7 @@ func TestHookConfig_ValidateDirInference(t *testing.T) { name: "InlineScriptDirUnchanged", config: HookConfig{ Name: "test", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: "echo hello", }, expectedDir: "", diff --git a/cli/azd/pkg/ext/python_hooks_e2e_test.go b/cli/azd/pkg/ext/python_hooks_e2e_test.go index 73191fa91fe..8892b0ebb6c 100644 --- a/cli/azd/pkg/ext/python_hooks_e2e_test.go +++ b/cli/azd/pkg/ext/python_hooks_e2e_test.go @@ -614,7 +614,7 @@ func TestPythonHook_ShellHookUnaffected(t *testing.T) { hooksMap := map[string][]*HookConfig{ "prebuild": {{ Name: "prebuild", - Shell: "sh", + Shell: string(language.ScriptLanguageBash), Run: filepath.Join( "hooks", "prebuild.sh", ), diff --git a/cli/azd/pkg/tools/language/python_executor.go b/cli/azd/pkg/tools/language/python_executor.go index af29c570269..beba7d4c21a 100644 --- a/cli/azd/pkg/tools/language/python_executor.go +++ b/cli/azd/pkg/tools/language/python_executor.go @@ -76,6 +76,13 @@ func newPythonExecutorInternal( // file is found, creates a virtual environment and installs // dependencies. The venv naming convention follows // [framework_service_python.go]: {projectDirName}_env. +// +// Before creating a new venv, Prepare checks for an existing +// virtual environment — either via the VIRTUAL_ENV environment +// variable or well-known directory names (.venv, venv) in the +// project path. When an existing venv is detected, creation is +// skipped but dependency installation still runs when a Python +// project file is present and the venv is inside the project. func (e *pythonExecutor) Prepare( ctx context.Context, scriptPath string, @@ -84,8 +91,10 @@ func (e *pythonExecutor) Prepare( // 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", + "python 3 is required to run this hook "+ + "but was not found on PATH. Install "+ + "Python from https://www.python.org"+ + "/downloads/ : %w", err, ) } @@ -100,25 +109,66 @@ func (e *pythonExecutor) Prepare( ) } + // 3. Detect existing virtual environment (VIRTUAL_ENV + // env var or well-known directories). + if existing := detectExistingVenv( + execCtx, projCtx, + ); existing != "" { + e.venvPath = existing + + // Still install deps when a Python project file + // exists and the venv lives inside projectDir (so + // the python CLI can locate the activation script). + if projCtx != nil && + projCtx.Language == ScriptLanguagePython { + if name, ok := relativeVenvName( + projCtx.ProjectDir, existing, + ); ok { + depFile := filepath.Base( + projCtx.DependencyFile, + ) + if err := e.installDeps( + ctx, projCtx.ProjectDir, + name, depFile, + execCtx.EnvVars, + ); err != nil { + return err + } + } + } + return nil + } + // No project file — run with system Python directly. if projCtx == nil { return nil } - // 3. Set up virtual environment. + // Skip venv setup if the discovered project is not + // Python (e.g. a package.json for JS living near the + // Python script). + if projCtx.Language != ScriptLanguagePython { + return nil + } + + // 4. Set up virtual environment. venvName := venvNameForDir(projCtx.ProjectDir) - venvPath := filepath.Join(projCtx.ProjectDir, venvName) + venvPath := filepath.Join( + projCtx.ProjectDir, venvName, + ) if err := e.ensureVenv( - ctx, projCtx.ProjectDir, venvName, venvPath, execCtx.EnvVars, + ctx, projCtx.ProjectDir, + venvName, venvPath, execCtx.EnvVars, ); err != nil { return err } - // 4. Install dependencies from the discovered file. + // 5. Install dependencies from the discovered file. depFile := filepath.Base(projCtx.DependencyFile) if err := e.installDeps( - ctx, projCtx.ProjectDir, venvName, depFile, execCtx.EnvVars, + ctx, projCtx.ProjectDir, + venvName, depFile, execCtx.EnvVars, ); err != nil { return err } @@ -281,6 +331,77 @@ func resolvePythonCmd( return "python3" } +// detectExistingVenv checks for an active or pre-existing virtual +// environment. It inspects the VIRTUAL_ENV environment variable +// first, then looks for well-known venv directory names (.venv, +// venv) inside the project directory. Returns the venv directory +// path or empty string when none is found. +func detectExistingVenv( + execCtx tools.ExecutionContext, + projCtx *ProjectContext, +) string { + // 1. VIRTUAL_ENV from the process environment. + if venv := envVarValue( + execCtx.EnvVars, "VIRTUAL_ENV", + ); venv != "" { + info, err := os.Stat(venv) + if err == nil && info.IsDir() { + return venv + } + } + + // 2. Well-known venv directories in the project path. + if projCtx == nil { + return "" + } + for _, name := range []string{".venv", "venv"} { + candidate := filepath.Join( + projCtx.ProjectDir, name, + ) + if hasPyvenvCfg(candidate) { + return candidate + } + } + return "" +} + +// hasPyvenvCfg returns true when dir contains a pyvenv.cfg file, +// indicating it is a Python virtual environment. +func hasPyvenvCfg(dir string) bool { + info, err := os.Stat( + filepath.Join(dir, "pyvenv.cfg"), + ) + return err == nil && !info.IsDir() +} + +// envVarValue extracts the value of a KEY=value pair from a +// slice of environment strings. Returns empty string if not +// found. +func envVarValue(envVars []string, key string) string { + prefix := key + "=" + for _, v := range envVars { + if strings.HasPrefix(v, prefix) { + return v[len(prefix):] + } + } + return "" +} + +// relativeVenvName computes the relative directory name of +// venvPath within projectDir. Returns the name and true when +// the venv is a direct child of projectDir; returns ("", false) +// when the venv is outside projectDir or at the root itself. +func relativeVenvName( + projectDir, venvPath string, +) (string, bool) { + rel, err := filepath.Rel(projectDir, venvPath) + if err != nil || + strings.HasPrefix(rel, "..") || rel == "." { + return "", false + } + return rel, true +} + // 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. diff --git a/cli/azd/pkg/tools/language/python_executor_test.go b/cli/azd/pkg/tools/language/python_executor_test.go index 1cda4fd0ced..72597f32ffd 100644 --- a/cli/azd/pkg/tools/language/python_executor_test.go +++ b/cli/azd/pkg/tools/language/python_executor_test.go @@ -411,3 +411,268 @@ func TestPythonExecute_WorkingDirectory(t *testing.T) { assert.Equal(t, scriptDir, runner.lastRunArgs.Cwd) }) } + +// --------------------------------------------------------------------------- +// Existing venv detection tests +// --------------------------------------------------------------------------- + +func TestPythonPrepare_VirtualEnvEnvVar(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", + ) + + // Create a venv outside the project tree and add + // pyvenv.cfg so it looks like a real venv. + externalVenv := filepath.Join(root, "shared-venv") + require.NoError(t, os.MkdirAll(externalVenv, 0o700)) + writeFile( + t, + filepath.Join(externalVenv, "pyvenv.cfg"), + "home = /usr/bin\n", + ) + + cli := &mockPythonTools{} + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{ + BoundaryDir: root, + EnvVars: []string{ + "VIRTUAL_ENV=" + externalVenv, + }, + } + scriptPath := filepath.Join(projectDir, "hook.py") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + assert.Equal(t, externalVenv, e.venvPath, + "should use VIRTUAL_ENV path") + assert.False(t, cli.createVenvCalled, + "should skip venv creation") + // External venv → dep installation is skipped because + // the venv is outside the project directory. + assert.False(t, cli.installReqCalled, + "should skip dep install for external venv") +} + +func TestPythonPrepare_VirtualEnvInsideProject(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", + ) + + // Create a venv inside the project. + localVenv := filepath.Join(projectDir, "my_env") + require.NoError(t, os.MkdirAll(localVenv, 0o700)) + writeFile( + t, + filepath.Join(localVenv, "pyvenv.cfg"), + "home = /usr/bin\n", + ) + + cli := &mockPythonTools{} + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{ + BoundaryDir: root, + EnvVars: []string{ + "VIRTUAL_ENV=" + localVenv, + }, + } + scriptPath := filepath.Join(projectDir, "hook.py") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + assert.Equal(t, localVenv, e.venvPath) + assert.False(t, cli.createVenvCalled, + "should skip venv creation") + assert.True(t, cli.installReqCalled, + "should install deps for local venv") + assert.Equal(t, "my_env", cli.reqVenv, + "should pass relative venv name") +} + +func TestPythonPrepare_DotVenvDirectory(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", + ) + + // Create a .venv directory with pyvenv.cfg. + dotVenv := filepath.Join(projectDir, ".venv") + require.NoError(t, os.MkdirAll(dotVenv, 0o700)) + writeFile( + t, + filepath.Join(dotVenv, "pyvenv.cfg"), + "home = /usr/bin\n", + ) + + cli := &mockPythonTools{} + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{BoundaryDir: root} + scriptPath := filepath.Join(projectDir, "hook.py") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + assert.Equal(t, dotVenv, e.venvPath, + "should detect .venv directory") + assert.False(t, cli.createVenvCalled, + "should skip venv creation") + assert.True(t, cli.installReqCalled, + "should still install requirements") + assert.Equal(t, ".venv", cli.reqVenv, + "should use .venv as venv name") +} + +func TestPythonPrepare_VenvDirWithoutPyvenvCfg(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", + ) + + // Create a .venv directory WITHOUT pyvenv.cfg — should + // not be detected as an existing venv. + dotVenv := filepath.Join(projectDir, ".venv") + require.NoError(t, os.MkdirAll(dotVenv, 0o700)) + + cli := &mockPythonTools{} + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{BoundaryDir: root} + scriptPath := filepath.Join(projectDir, "hook.py") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + // Falls through to normal venv creation flow. + assert.True(t, cli.createVenvCalled, + "should create venv when .venv has no pyvenv.cfg") + assert.Equal(t, "myproject_env", cli.venvName) +} + +func TestPythonPrepare_VirtualEnvInvalid(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", + ) + + cli := &mockPythonTools{} + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + // VIRTUAL_ENV points to a non-existent directory. + execCtx := tools.ExecutionContext{ + BoundaryDir: root, + EnvVars: []string{ + "VIRTUAL_ENV=/nonexistent/path", + }, + } + scriptPath := filepath.Join(projectDir, "hook.py") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + // Should fall through to normal venv creation. + assert.True(t, cli.createVenvCalled, + "should create venv when VIRTUAL_ENV is invalid") +} + +func TestPythonPrepare_VirtualEnvNoProjectFile(t *testing.T) { + dir := t.TempDir() + + // Create a venv directory. + venvDir := filepath.Join(dir, "myvenv") + require.NoError(t, os.MkdirAll(venvDir, 0o700)) + writeFile( + t, + filepath.Join(venvDir, "pyvenv.cfg"), + "home = /usr/bin\n", + ) + + cli := &mockPythonTools{} + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{ + BoundaryDir: dir, + EnvVars: []string{ + "VIRTUAL_ENV=" + venvDir, + }, + } + scriptPath := filepath.Join(dir, "hook.py") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + assert.Equal(t, venvDir, e.venvPath, + "should use VIRTUAL_ENV even without project file") + assert.False(t, cli.createVenvCalled) + assert.False(t, cli.installReqCalled) +} + +func TestPythonPrepare_VenvDirVenv(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", + ) + + // Create "venv" directory (not ".venv") with pyvenv.cfg. + venvDir := filepath.Join(projectDir, "venv") + require.NoError(t, os.MkdirAll(venvDir, 0o700)) + writeFile( + t, + filepath.Join(venvDir, "pyvenv.cfg"), + "home = /usr/bin\n", + ) + + cli := &mockPythonTools{} + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{BoundaryDir: root} + scriptPath := filepath.Join( + projectDir, "hook.py", + ) + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + assert.Equal(t, venvDir, e.venvPath, + "should detect venv directory") + assert.False(t, cli.createVenvCalled) + assert.True(t, cli.installProjCalled, + "should install project deps") + assert.Equal(t, "venv", cli.projVenv) +} diff --git a/cli/azd/pkg/tools/powershell/powershell.go b/cli/azd/pkg/tools/powershell/powershell.go index 53b0b4c1941..d111ae6b749 100644 --- a/cli/azd/pkg/tools/powershell/powershell.go +++ b/cli/azd/pkg/tools/powershell/powershell.go @@ -28,6 +28,7 @@ type powershellExecutor struct { commandRunner exec.CommandRunner shellCmd string // resolved in Prepare: "pwsh" or "powershell" tempFile string // temp script created from inline content + usingFallback bool // true when PS5 "powershell" is used instead of "pwsh" } // Prepare validates that PowerShell is available. Tries pwsh first, @@ -84,6 +85,7 @@ func (p *powershellExecutor) resolvePowerShell() error { if runtime.GOOS == "windows" { if p.commandRunner.ToolInPath("powershell") == nil { p.shellCmd = "powershell" + p.usingFallback = true return nil } return &internal.ErrorWithSuggestion{ @@ -130,7 +132,19 @@ func (p *powershellExecutor) Execute( runArgs = runArgs.WithStdOut(execCtx.StdOut) } - return p.commandRunner.Run(ctx, runArgs) + res, err := p.commandRunner.Run(ctx, runArgs) + if err != nil && p.usingFallback { + return res, fmt.Errorf( + "%w\n\nNote: pwsh was not found on PATH and "+ + "powershell (Windows PowerShell 5) was "+ + "automatically used instead. Consider "+ + "installing PowerShell 7+ (pwsh) from "+ + "https://learn.microsoft.com/powershell/"+ + "scripting/install/installing-powershell", + err, + ) + } + return res, err } // Cleanup removes any temporary script file created during Prepare. From 30a93d7374e2c0698b2332ba38adac784dc3dab6 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 7 Apr 2026 10:58:50 -0700 Subject: [PATCH 14/25] =?UTF-8?q?refactor:=20rename=20Language=E2=86=92Kin?= =?UTF-8?q?d,=20ScriptLanguage=E2=86=92HookKind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify the executor type naming: - HookConfig.Language → HookConfig.Kind (primary YAML field: kind) - ScriptLanguage → HookKind (type name) - ScriptLanguageBash → HookKindBash (and all constants) - IsShellLanguage() → IsShell() - InferLanguageFromPath() → InferKindFromPath() - defaultLanguageForOS() → defaultKindForOS() - shellToLanguage() → shellToKind() Keep language: and shell: as backwards-compatible YAML aliases that map to Kind during validation. Priority: kind > language > shell > file extension > OS default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/container.go | 12 +- cli/azd/cmd/hooks_test.go | 12 +- cli/azd/cmd/middleware/hooks_test.go | 32 +-- cli/azd/pkg/ext/hooks_config_test.go | 12 +- cli/azd/pkg/ext/hooks_manager.go | 19 +- cli/azd/pkg/ext/hooks_manager_test.go | 18 +- cli/azd/pkg/ext/hooks_runner.go | 16 +- cli/azd/pkg/ext/hooks_runner_test.go | 16 +- cli/azd/pkg/ext/models.go | 125 +++++---- cli/azd/pkg/ext/models_test.go | 263 +++++++++--------- cli/azd/pkg/ext/python_hooks_e2e_test.go | 26 +- cli/azd/pkg/tools/language/executor.go | 78 +++--- cli/azd/pkg/tools/language/executor_test.go | 34 +-- .../pkg/tools/language/project_discovery.go | 18 +- .../tools/language/project_discovery_test.go | 20 +- cli/azd/pkg/tools/language/python_executor.go | 4 +- .../test/mocks/mocktools/hook_executors.go | 6 +- 17 files changed, 366 insertions(+), 345 deletions(-) diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index d3c29691e0a..1b040c56e7d 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -816,14 +816,14 @@ func registerCommonDependencies(container *ioc.NestedContainer) { // 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, + hookExecutorMap := map[language.HookKind]any{ + language.HookKindBash: bash.NewExecutor, + language.HookKindPowerShell: powershell.NewExecutor, + language.HookKindPython: language.NewPythonExecutor, } - for lang, constructor := range hookExecutorMap { - container.MustRegisterNamedTransient(string(lang), constructor) + for kind, constructor := range hookExecutorMap { + container.MustRegisterNamedTransient(string(kind), constructor) } // Pipelines diff --git a/cli/azd/cmd/hooks_test.go b/cli/azd/cmd/hooks_test.go index 3a196d546ff..f0bc5c4d7ad 100644 --- a/cli/azd/cmd/hooks_test.go +++ b/cli/azd/cmd/hooks_test.go @@ -50,7 +50,7 @@ func Test_HooksRunAction_RunsLayerHooks(t *testing.T) { Path: "infra/core", Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "echo core", }}, }, @@ -60,7 +60,7 @@ func Test_HooksRunAction_RunsLayerHooks(t *testing.T) { Path: absoluteLayerPath, Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "echo shared", }}, }, @@ -118,7 +118,7 @@ func Test_HooksRunAction_FiltersLayerHooks(t *testing.T) { Path: "infra/core", Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "echo core", }}, }, @@ -128,7 +128,7 @@ func Test_HooksRunAction_FiltersLayerHooks(t *testing.T) { Path: "infra/shared", Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "echo shared", }}, }, @@ -188,7 +188,7 @@ func Test_HooksRunAction_SetsTelemetryTypeForLayer(t *testing.T) { Path: "infra/core", Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "echo core", }}, }, @@ -283,5 +283,5 @@ func Test_HooksRunAction_ValidatesLayerHooksRelativeToLayerPath(t *testing.T) { require.NoError(t, err) require.False(t, layerHook.IsUsingDefaultShell()) // validate() infers language from the .sh file extension - require.Equal(t, language.ScriptLanguageBash, layerHook.Language) + require.Equal(t, language.HookKindBash, layerHook.Kind) } diff --git a/cli/azd/cmd/middleware/hooks_test.go b/cli/azd/cmd/middleware/hooks_test.go index 1d2c7fab0bf..f38ae5c5dd7 100644 --- a/cli/azd/cmd/middleware/hooks_test.go +++ b/cli/azd/cmd/middleware/hooks_test.go @@ -43,7 +43,7 @@ func Test_CommandHooks_Middleware_WithValidProjectAndMatchingCommand(t *testing. "precommand": { { Run: "echo 'hello'", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), }, }, }, @@ -78,7 +78,7 @@ func Test_CommandHooks_Middleware_ValidProjectWithDifferentCommand(t *testing.T) "precommand": { { Run: "echo 'hello'", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), }, }, }, @@ -140,7 +140,7 @@ func Test_CommandHooks_Middleware_PreHookWithError(t *testing.T) { "precommand": { { Run: "exit 1", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), }, }, }, @@ -178,7 +178,7 @@ func Test_CommandHooks_Middleware_PreHookWithErrorAndContinue(t *testing.T) { "precommand": { { Run: "exit 1", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), ContinueOnError: true, }, }, @@ -217,7 +217,7 @@ func Test_CommandHooks_Middleware_WithCmdAlias(t *testing.T) { "prealias": { { Run: "echo 'hello'", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), }, }, }, @@ -259,7 +259,7 @@ func Test_ServiceHooks_Registered(t *testing.T) { Hooks: map[string][]*ext.HookConfig{ "predeploy": { { - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "echo 'Hello'", }, }, @@ -536,7 +536,7 @@ func Test_PowerShellWarning_WithPowerShellHooks(t *testing.T) { "preprovision": { { Run: "Write-Host 'hello'", - Shell: string(language.ScriptLanguagePowerShell), + Shell: string(language.HookKindPowerShell), }, }, }, @@ -584,8 +584,8 @@ func Test_PowerShellWarning_WithPs1FileHook(t *testing.T) { Hooks: map[string][]*ext.HookConfig{ "preprovision": { { - Run: "script.ps1", // PowerShell file extension - Shell: string(language.ScriptLanguagePowerShell), // Explicitly specify shell to avoid detection issues + Run: "script.ps1", // PowerShell file extension + Shell: string(language.HookKindPowerShell), // Explicitly specify shell to avoid detection issues }, }, }, @@ -632,7 +632,7 @@ func Test_PowerShellWarning_WithoutPowerShellHooks(t *testing.T) { "precommand": { { Run: "echo 'hello'", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), }, }, }, @@ -711,7 +711,7 @@ func Test_CommandHooks_ChildAction_HooksStillFire(t *testing.T) { tt.hookName: { { Run: "echo 'hook running'", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), }, }, }, @@ -872,13 +872,13 @@ func Test_CommandHooks_ChildAction_SkipsValidationOnly(t *testing.T) { "preprovision": { { Run: "echo 'preprovision hook'", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), }, }, "postprovision": { { Run: "echo 'postprovision hook'", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), }, }, }, @@ -929,7 +929,7 @@ func Test_CommandHooks_ChildAction_PreHookError_StopsAction(t *testing.T) { "preprovision": { { Run: "exit 1", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), }, }, }, @@ -967,7 +967,7 @@ func Test_PowerShellWarning_WithPwshAvailable(t *testing.T) { "precommand": { { Run: "Write-Host 'hello'", - Shell: string(language.ScriptLanguagePowerShell), + Shell: string(language.HookKindPowerShell), }, }, }, @@ -1014,7 +1014,7 @@ func Test_PowerShellWarning_WithNoPowerShellInstalled(t *testing.T) { "preprovision": { { Run: "Write-Host 'hello'", - Shell: string(language.ScriptLanguagePowerShell), + Shell: string(language.HookKindPowerShell), }, }, }, diff --git a/cli/azd/pkg/ext/hooks_config_test.go b/cli/azd/pkg/ext/hooks_config_test.go index c3a00331635..ee7eb8556df 100644 --- a/cli/azd/pkg/ext/hooks_config_test.go +++ b/cli/azd/pkg/ext/hooks_config_test.go @@ -27,7 +27,7 @@ preprovision: require.Equal(t, HooksConfig{ "preprovision": { { - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "scripts/preprovision.sh", }, }, @@ -50,11 +50,11 @@ preprovision: require.Equal(t, HooksConfig{ "preprovision": { { - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "scripts/preprovision-1.sh", }, { - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "scripts/preprovision-2.sh", }, }, @@ -67,7 +67,7 @@ func TestHooksConfig_MarshalYAML(t *testing.T) { hooks := HooksConfig{ "preprovision": { { - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "scripts/preprovision.sh", }, }, @@ -87,11 +87,11 @@ preprovision: hooks := HooksConfig{ "preprovision": { { - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "scripts/preprovision-1.sh", }, { - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "scripts/preprovision-2.sh", }, }, diff --git a/cli/azd/pkg/ext/hooks_manager.go b/cli/azd/pkg/ext/hooks_manager.go index d67b0b9af5f..6479f9791a7 100644 --- a/cli/azd/pkg/ext/hooks_manager.go +++ b/cli/azd/pkg/ext/hooks_manager.go @@ -176,12 +176,13 @@ func (h *HooksManager) ValidateHooks(ctx context.Context, allHooks map[string][] _, err := os.Stat(fullCheckPath) isInlineScript := err != nil // File doesn't exist, so it's inline - // If no language/shell and it's an inline script, set - // OS default Language for warning purposes. + // If no kind/language/shell and it's an inline script, set + // OS default Kind for warning purposes. if hookConfig.Shell == "" && - hookConfig.Language == language.ScriptLanguageUnknown && + hookConfig.Language == "" && + hookConfig.Kind == language.HookKindUnknown && isInlineScript { - hookConfig.Language = defaultLanguageForOS() + hookConfig.Kind = defaultKindForOS() hookConfig.usingDefaultShell = true } } @@ -271,7 +272,7 @@ func (h *HooksManager) validateRuntimes( // Collect unique language runtimes required across all hooks. // Track the first hook name per language for actionable messages. - requiredLangs := map[language.ScriptLanguage]string{} + requiredLangs := map[language.HookKind]string{} for hookName, hookConfigs := range allHooks { for _, hookConfig := range hookConfigs { @@ -310,16 +311,16 @@ func (h *HooksManager) validateRuntimes( // Non-shell hooks need runtime validation // (e.g. Python must be installed). Bash and // PowerShell hooks are validated separately above. - if !cfg.Language.IsShellLanguage() { - if _, seen := requiredLangs[cfg.Language]; !seen { - requiredLangs[cfg.Language] = hookName + if !cfg.Kind.IsShell() { + if _, seen := requiredLangs[cfg.Kind]; !seen { + requiredLangs[cfg.Kind] = hookName } } } } // Phase 1: validate Python runtime. - if hookName, ok := requiredLangs[language.ScriptLanguagePython]; ok { + if hookName, ok := requiredLangs[language.HookKindPython]; ok { pythonCli := python.NewCli(h.commandRunner) if err := pythonCli.CheckInstalled(ctx); err != nil { warnings = append(warnings, HookWarning{ diff --git a/cli/azd/pkg/ext/hooks_manager_test.go b/cli/azd/pkg/ext/hooks_manager_test.go index ee09ac92657..e66d3ce19e4 100644 --- a/cli/azd/pkg/ext/hooks_manager_test.go +++ b/cli/azd/pkg/ext/hooks_manager_test.go @@ -52,13 +52,13 @@ func Test_GetAllHookConfigs(t *testing.T) { hooksMap := map[string][]*HookConfig{ "preinit": { { - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), // Run is missing - this should cause an error }, }, "postinit": { { - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), // Run is missing - this should cause an error }, }, @@ -124,13 +124,13 @@ func Test_GetByParams(t *testing.T) { hooksMap := map[string][]*HookConfig{ "preinit": { { - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), // Run is missing - this should cause an error }, }, "postinit": { { - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), // Run is missing - this should cause an error }, }, @@ -151,7 +151,7 @@ func Test_HookConfig_DefaultShell(t *testing.T) { tests := []struct { name string hookConfig *HookConfig - expectedLanguage language.ScriptLanguage + expectedKind language.HookKind expectingDefault bool }{ { @@ -160,17 +160,17 @@ func Test_HookConfig_DefaultShell(t *testing.T) { Name: "test", Run: "echo 'hello'", }, - expectedLanguage: defaultLanguageForOS(), + expectedKind: defaultKindForOS(), expectingDefault: true, }, { name: "Shell explicitly specified - should not use default", hookConfig: &HookConfig{ Name: "test", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "echo 'hello'", }, - expectedLanguage: language.ScriptLanguageBash, + expectedKind: language.HookKindBash, expectingDefault: false, }, } @@ -184,7 +184,7 @@ func Test_HookConfig_DefaultShell(t *testing.T) { err := config.validate() require.NoError(t, err) - require.Equal(t, tt.expectedLanguage, config.Language) + require.Equal(t, tt.expectedKind, config.Kind) require.Equal(t, tt.expectingDefault, config.IsUsingDefaultShell()) }) } diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index 94f752f65f6..d6f4504ddd6 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -169,7 +169,7 @@ func (h *HooksRunner) execHook( dir = filepath.Join(boundaryDir, dir) } cwd = dir - } else if hookConfig.path != "" && !hookConfig.Language.IsShellLanguage() { + } else if hookConfig.path != "" && !hookConfig.Kind.IsShell() { // Non-shell languages (Python, JS/TS, .NET) derive cwd from // the script's directory so that project-relative tooling // (venvs, node_modules) resolves correctly. Shell hooks @@ -220,18 +220,18 @@ func (h *HooksRunner) execHook( // 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 { + if err := h.serviceLocator.ResolveNamed(string(hookConfig.Kind), &executor); err != nil { return &errorhandler.ErrorWithSuggestion{ Err: fmt.Errorf( - "no executor for language '%s': %w", - hookConfig.Language, err, + "no executor for kind '%s': %w", + hookConfig.Kind, err, ), Message: fmt.Sprintf( - "The '%s' language is not supported for hook '%s'.", - hookConfig.Language, + "The '%s' kind is not supported for hook '%s'.", + hookConfig.Kind, hookConfig.Name, ), - Suggestion: "Supported hook languages: sh, pwsh, python.", + Suggestion: "Supported hook kinds: sh, pwsh, python.", Links: []errorhandler.ErrorLink{ { Title: "Hook documentation", @@ -252,7 +252,7 @@ func (h *HooksRunner) execHook( // PowerShell, inline temp file creation for Bash/PowerShell hooks). log.Printf( "Preparing hook '%s' (%s)\n", - hookConfig.Name, hookConfig.Language, + hookConfig.Name, hookConfig.Kind, ) if err := executor.Prepare(ctx, scriptPath, execCtx); err != nil { diff --git a/cli/azd/pkg/ext/hooks_runner_test.go b/cli/azd/pkg/ext/hooks_runner_test.go index 933e33b4bdf..88058c0ce05 100644 --- a/cli/azd/pkg/ext/hooks_runner_test.go +++ b/cli/azd/pkg/ext/hooks_runner_test.go @@ -44,24 +44,24 @@ func Test_Hooks_Execute(t *testing.T) { hooksMap := map[string][]*HookConfig{ "preinline": { { - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "echo 'Hello'", }, }, "precommand": { { - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "scripts/precommand.sh", }, }, "postcommand": {{ - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "scripts/postcommand.sh", }, }, "preinteractive": { { - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "scripts/preinteractive.sh", Interactive: true, }, @@ -315,7 +315,7 @@ func Test_Hooks_Validation(t *testing.T) { hooksMap := map[string][]*HookConfig{ "predeploy": {{ Name: "predeploy", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "scripts/script.sh", }}, } @@ -377,7 +377,7 @@ func Test_Hooks_Validation(t *testing.T) { hooksMap := map[string][]*HookConfig{ "preinline": {{ Name: "preinline", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "echo 'Hello'", }}, } @@ -407,7 +407,7 @@ func Test_Hooks_Validation(t *testing.T) { hooksMap := map[string][]*HookConfig{ "predeploy": {{ Name: "predeploy", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), }}, } @@ -508,7 +508,7 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { "predeploy": { { Name: "predeploy", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "scripts/predeploy.sh", }, }, diff --git a/cli/azd/pkg/ext/models.go b/cli/azd/pkg/ext/models.go index 94709d1052d..273c1446569 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -33,11 +33,11 @@ const ( ) var ( - // ErrScriptTypeUnknown indicates the language could not be inferred + // ErrScriptTypeUnknown indicates the hook kind could not be inferred // from the script path and was not explicitly configured. ErrScriptTypeUnknown error = errors.New( - "unable to determine hook language. " + - "Ensure 'shell' or 'language' is set in your hook configuration, " + + "unable to determine hook kind. " + + "Ensure 'kind', 'language', or 'shell' is set in your hook configuration, " + "or use a file with a recognized extension " + "(.sh, .ps1, .py, .js, .ts, .cs)", ) @@ -47,11 +47,12 @@ var ( "Set 'run' to an inline script or a relative file path", ) // ErrUnsupportedScriptType indicates the script file has an extension - // that is not recognized and no explicit language or shell was set. + // that is not recognized and no explicit kind, language, or shell was set. ErrUnsupportedScriptType error = errors.New( "script type is not valid. " + "Supported extensions: .sh, .ps1, .py, .js, .ts, .cs. " + - "Alternatively, set 'language' (e.g. language: python) " + + "Alternatively, set 'kind' (e.g. kind: python), " + + "'language' (e.g. language: python), " + "or 'shell' (e.g. shell: sh)", ) ) @@ -62,7 +63,7 @@ type InvokeFn func() error // HookConfig defines the configuration for a single hook in azure.yaml. // Hooks are lifecycle scripts that run before or after azd commands. // Every hook is executed through a [tools.HookExecutor] resolved via IoC -// based on the hook's Language — including Bash, PowerShell, Python, +// based on the hook's Kind — including Bash, PowerShell, Python, // and future executor types (JS, TS, DotNet). type HookConfig struct { // When set, contains the resolved file path relative to the project or service @@ -78,18 +79,22 @@ type HookConfig struct { // Internal name of the hook running for a given command Name string `yaml:",omitempty"` - // The type of script hook (bash or powershell). - // Retained as a string for backwards-compatible YAML - // deserialization. After validate(), only Language is used. - Shell string `yaml:"shell,omitempty"` - // Language specifies the programming language of the hook script. + // Kind specifies the executor type 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"` + // When empty, the kind is auto-detected from the file extension + // of the run path (e.g. .py → python, .ps1 → pwsh). If Kind, + // Language, and Shell are all empty and run references a file, + // the extension is used. For inline scripts, Kind, Shell, or + // Language must be set explicitly. + Kind language.HookKind `yaml:"kind,omitempty" json:"kind,omitempty"` + // Language is a deprecated alias for Kind. When set in YAML + // (language: python) it is mapped to Kind during validate(). + // Retained for backwards compatibility. + Language string `yaml:"language,omitempty" json:"-"` + // Shell is a deprecated alias for Kind. When set in YAML + // (shell: sh) it is mapped to Kind during validate(). + // Retained for backwards compatibility with legacy configs. + Shell string `yaml:"shell,omitempty" json:"-"` // 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 @@ -112,16 +117,17 @@ type HookConfig struct { } // validate normalizes and validates the hook configuration. It resolves -// the script location (inline vs. file path) and ensures that Language -// is always resolved to a concrete [language.ScriptLanguage] value. +// the script location (inline vs. file path) and ensures that Kind +// is always resolved to a concrete [language.HookKind] value. // -// Language resolution priority: -// 1. Explicit Language field -// 2. Explicit Shell field (mapped to Language) -// 3. File extension via [language.InferLanguageFromPath] -// 4. OS default (Bash on Unix, PowerShell on Windows) for inline scripts +// Kind resolution priority: +// 1. Explicit Kind field +// 2. Explicit Language field (deprecated alias, mapped to Kind) +// 3. Explicit Shell field (deprecated alias, mapped to Kind) +// 4. File extension via [language.InferKindFromPath] +// 5. OS default (Bash on Unix, PowerShell on Windows) for inline scripts // -// After a successful call, Language is the single source of truth for +// After a successful call, Kind is the single source of truth for // executor selection and the hook is ready for execution. func (hc *HookConfig) validate() error { if hc.validated { @@ -147,39 +153,38 @@ func (hc *HookConfig) validate() error { hc.script = hc.Run } - // Language resolution — priority: - // 1. explicit Language 2. explicit Shell 3. file extension - // 4. OS default for inline scripts - if hc.Language == language.ScriptLanguageUnknown { - switch { - case hc.Shell != "": - hc.Language = shellToLanguage(hc.Shell) - case hc.path != "": - hc.Language = language.InferLanguageFromPath( - hc.Run, - ) - } + // Kind resolution — priority: + // 1. explicit Kind 2. Language alias 3. Shell alias + // 4. file extension 5. OS default for inline scripts + if hc.Kind == language.HookKindUnknown && hc.Language != "" { + hc.Kind = language.HookKind(hc.Language) + } + if hc.Kind == language.HookKindUnknown && hc.Shell != "" { + hc.Kind = language.HookKind(hc.Shell) + } + if hc.Kind == language.HookKindUnknown && hc.path != "" { + hc.Kind = language.InferKindFromPath(hc.Run) } // Reject inline scripts for non-shell executors. Only Bash and // PowerShell executors support inline script content; other // executors require a file on disk. if hc.script != "" && - hc.Language != language.ScriptLanguageUnknown && - !hc.Language.IsShellLanguage() { + hc.Kind != language.HookKindUnknown && + !hc.Kind.IsShell() { 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, + hc.Kind, ) } // Non-shell executors handle their own runtime and dependency // setup; no shell type resolution or temp script is needed. - if hc.Language != language.ScriptLanguageUnknown && - !hc.Language.IsShellLanguage() { + if hc.Kind != language.HookKindUnknown && + !hc.Kind.IsShell() { // Auto-infer Dir from the script's directory when not // explicitly set by the user. if hc.Dir == "" && hc.path != "" { @@ -189,16 +194,16 @@ func (hc *HookConfig) validate() error { return nil } - // For inline scripts with no resolved language, default to the - // OS-appropriate shell language. - if hc.Language == language.ScriptLanguageUnknown && + // For inline scripts with no resolved kind, default to the + // OS-appropriate shell kind. + if hc.Kind == language.HookKindUnknown && hc.script != "" { - hc.Language = defaultLanguageForOS() + hc.Kind = defaultKindForOS() hc.usingDefaultShell = true } // For file-based hooks with an unrecognized extension, error. - if hc.Language == language.ScriptLanguageUnknown { + if hc.Kind == language.HookKindUnknown { return fmt.Errorf( "script with file extension '%s' is not valid. "+ "%w.", @@ -213,11 +218,11 @@ func (hc *HookConfig) validate() error { } // IsPowerShellHook determines if a hook configuration uses PowerShell. -// It checks the resolved Language first, then falls back to the raw +// It checks the resolved Kind first, then falls back to the raw // Shell field and file extension for hooks that have not yet been // validated. func (hc *HookConfig) IsPowerShellHook() bool { - if hc.Language == language.ScriptLanguagePowerShell { + if hc.Kind == language.HookKindPowerShell { return true } @@ -266,25 +271,25 @@ func InferHookType(name string) (HookType, string) { return HookTypeNone, name } -// defaultLanguageForOS returns the default shell language for the +// defaultKindForOS returns the default shell kind for the // current operating system: PowerShell on Windows, Bash elsewhere. -func defaultLanguageForOS() language.ScriptLanguage { +func defaultKindForOS() language.HookKind { if runtime.GOOS == "windows" { - return language.ScriptLanguagePowerShell + return language.HookKindPowerShell } - return language.ScriptLanguageBash + return language.HookKindBash } -// shellToLanguage maps a raw shell string (from the YAML "shell" -// field) to the corresponding [language.ScriptLanguage]. Returns -// [language.ScriptLanguageUnknown] for unrecognized values. -func shellToLanguage(shell string) language.ScriptLanguage { +// shellToKind maps a raw shell string (from the YAML "shell" +// field) to the corresponding [language.HookKind]. Returns +// [language.HookKindUnknown] for unrecognized values. +func shellToKind(shell string) language.HookKind { switch shell { case "sh": - return language.ScriptLanguageBash + return language.HookKindBash case "pwsh": - return language.ScriptLanguagePowerShell + return language.HookKindPowerShell default: - return language.ScriptLanguageUnknown + return language.HookKindUnknown } } diff --git a/cli/azd/pkg/ext/models_test.go b/cli/azd/pkg/ext/models_test.go index 9e7e70df9ff..5a8e0e0909e 100644 --- a/cli/azd/pkg/ext/models_test.go +++ b/cli/azd/pkg/ext/models_test.go @@ -13,67 +13,73 @@ import ( "gopkg.in/yaml.v3" ) -func TestHookConfig_LanguageField(t *testing.T) { +func TestHookConfig_KindField(t *testing.T) { tests := []struct { - name string - yamlInput string - expectedLanguage language.ScriptLanguage - expectedDir string + name string + yamlInput string + expectedKind language.HookKind + expectedDir string }{ { - name: "OmittedLanguageDefaultsToUnknown", - yamlInput: "run: scripts/hook.sh\n", - expectedLanguage: language.ScriptLanguageUnknown, - expectedDir: "", + name: "OmittedKindDefaultsToUnknown", + yamlInput: "run: scripts/hook.sh\n", + expectedKind: language.HookKindUnknown, + expectedDir: "", }, { - name: "OmittedDirDefaultsToEmpty", - yamlInput: "run: scripts/hook.py\nlanguage: python\n", - expectedLanguage: language.ScriptLanguagePython, - expectedDir: "", + name: "OmittedDirDefaultsToEmpty", + yamlInput: "run: scripts/hook.py\nkind: python\n", + expectedKind: language.HookKindPython, + expectedDir: "", }, { - name: "LanguagePython", - yamlInput: "run: scripts/hook.py\nlanguage: python\ndir: src/myapp\n", - expectedLanguage: language.ScriptLanguagePython, - expectedDir: "src/myapp", + name: "KindPython", + yamlInput: "run: scripts/hook.py\nkind: python\ndir: src/myapp\n", + expectedKind: language.HookKindPython, + expectedDir: "src/myapp", }, { - name: "LanguageJavaScript", - yamlInput: "run: hooks/prebuild.js\nlanguage: js\ndir: hooks\n", - expectedLanguage: language.ScriptLanguageJavaScript, - expectedDir: "hooks", + name: "KindJavaScript", + yamlInput: "run: hooks/prebuild.js\nkind: js\ndir: hooks\n", + expectedKind: language.HookKindJavaScript, + expectedDir: "hooks", }, { - name: "LanguageTypeScript", - yamlInput: "run: hooks/deploy.ts\nlanguage: ts\n", - expectedLanguage: language.ScriptLanguageTypeScript, - expectedDir: "", + name: "KindTypeScript", + yamlInput: "run: hooks/deploy.ts\nkind: ts\n", + expectedKind: language.HookKindTypeScript, + expectedDir: "", }, { - name: "LanguageDotNet", - yamlInput: "run: hooks/validate.csx\nlanguage: dotnet\ndir: hooks/dotnet\n", - expectedLanguage: language.ScriptLanguageDotNet, - expectedDir: "hooks/dotnet", + name: "KindDotNet", + yamlInput: "run: hooks/validate.csx\nkind: dotnet\ndir: hooks/dotnet\n", + expectedKind: language.HookKindDotNet, + expectedDir: "hooks/dotnet", }, { - name: "LanguageBash", - yamlInput: "run: scripts/setup.sh\nlanguage: sh\n", - expectedLanguage: language.ScriptLanguageBash, - expectedDir: "", + name: "KindBash", + yamlInput: "run: scripts/setup.sh\nkind: sh\n", + expectedKind: language.HookKindBash, + expectedDir: "", }, { - name: "LanguagePowerShell", - yamlInput: "run: scripts/setup.ps1\nlanguage: pwsh\n", - expectedLanguage: language.ScriptLanguagePowerShell, - expectedDir: "", + name: "KindPowerShell", + yamlInput: "run: scripts/setup.ps1\nkind: pwsh\n", + expectedKind: language.HookKindPowerShell, + expectedDir: "", }, { name: "AllFieldsTogether", yamlInput: "run: src/hooks/predeploy.py\nshell: sh\n" + - "language: python\ndir: src/hooks\ncontinueOnError: true\n", - expectedLanguage: language.ScriptLanguagePython, - expectedDir: "src/hooks", + "kind: python\ndir: src/hooks\ncontinueOnError: true\n", + expectedKind: language.HookKindPython, + expectedDir: "src/hooks", + }, + { + name: "LanguageAliasMapsPython", + yamlInput: "run: scripts/hook.py\nlanguage: python\n", + expectedKind: language.HookKindUnknown, // alias only resolved at validate() + expectedDir: "", }, } @@ -83,18 +89,17 @@ func TestHookConfig_LanguageField(t *testing.T) { err := yaml.Unmarshal([]byte(tt.yamlInput), &config) require.NoError(t, err) - require.Equal(t, tt.expectedLanguage, config.Language) + require.Equal(t, tt.expectedKind, config.Kind) require.Equal(t, tt.expectedDir, config.Dir) }) } } -func TestHookConfig_LanguageRoundTrip(t *testing.T) { +func TestHookConfig_KindRoundTrip(t *testing.T) { original := HookConfig{ - Run: "hooks/deploy.py", - Shell: string(language.ScriptLanguageBash), - Language: language.ScriptLanguagePython, - Dir: "hooks", + Run: "hooks/deploy.py", + Kind: language.HookKindPython, + Dir: "hooks", } data, err := yaml.Marshal(&original) @@ -104,74 +109,84 @@ func TestHookConfig_LanguageRoundTrip(t *testing.T) { err = yaml.Unmarshal(data, &decoded) require.NoError(t, err) - require.Equal(t, original.Language, decoded.Language) + require.Equal(t, original.Kind, decoded.Kind) 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) { +func TestHookKind_Constants(t *testing.T) { tests := []struct { name string - lang language.ScriptLanguage + kind language.HookKind 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"}, + {"Unknown", language.HookKindUnknown, ""}, + {"Bash", language.HookKindBash, "sh"}, + {"PowerShell", language.HookKindPowerShell, "pwsh"}, + {"JavaScript", language.HookKindJavaScript, "js"}, + {"TypeScript", language.HookKindTypeScript, "ts"}, + {"Python", language.HookKindPython, "python"}, + {"DotNet", language.HookKindDotNet, "dotnet"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.expected, string(tt.lang)) + require.Equal(t, tt.expected, string(tt.kind)) }) } } -func TestHookConfig_ValidateLanguageResolution(t *testing.T) { +func TestHookConfig_ValidateKindResolution(t *testing.T) { tests := []struct { - name string - config HookConfig - createFile string // relative path to create under cwd - expectedLanguage language.ScriptLanguage - isShellLanguage bool - expectError string + name string + config HookConfig + createFile string // relative path to create under cwd + expectedKind language.HookKind + isShell bool + expectError string }{ { - name: "ExplicitLanguagePythonFromFile", + name: "ExplicitKindPythonFromFile", config: HookConfig{ - Name: "test", - Language: language.ScriptLanguagePython, - Run: "script.py", + Name: "test", + Kind: language.HookKindPython, + Run: "script.py", }, - createFile: "script.py", - expectedLanguage: language.ScriptLanguagePython, - isShellLanguage: false, + createFile: "script.py", + expectedKind: language.HookKindPython, + isShell: false, }, { - name: "ExplicitLanguageOverridesExtension", + name: "ExplicitKindOverridesExtension", + config: HookConfig{ + Name: "test", + Kind: language.HookKindPython, + Run: "script.js", + }, + createFile: "script.js", + expectedKind: language.HookKindPython, + isShell: false, + }, + { + name: "LanguageAliasMapsToKind", config: HookConfig{ Name: "test", - Language: language.ScriptLanguagePython, - Run: "script.js", + Language: string(language.HookKindPython), + Run: "script.py", }, - createFile: "script.js", - expectedLanguage: language.ScriptLanguagePython, - isShellLanguage: false, + createFile: "script.py", + expectedKind: language.HookKindPython, + isShell: false, }, { - name: "ShellBashMapsToLanguage", + name: "ShellAliasBashMapsToKind", config: HookConfig{ Name: "test", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "echo hello", }, - expectedLanguage: language.ScriptLanguageBash, - isShellLanguage: true, + expectedKind: language.HookKindBash, + isShell: true, }, { name: "InferPythonFromExtension", @@ -179,9 +194,9 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { Name: "test", Run: "hooks/seed.py", }, - createFile: "hooks/seed.py", - expectedLanguage: language.ScriptLanguagePython, - isShellLanguage: false, + createFile: "hooks/seed.py", + expectedKind: language.HookKindPython, + isShell: false, }, { name: "InferJavaScriptFromExtension", @@ -189,9 +204,9 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { Name: "test", Run: "hooks/setup.js", }, - createFile: "hooks/setup.js", - expectedLanguage: language.ScriptLanguageJavaScript, - isShellLanguage: false, + createFile: "hooks/setup.js", + expectedKind: language.HookKindJavaScript, + isShell: false, }, { name: "InferTypeScriptFromExtension", @@ -199,9 +214,9 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { Name: "test", Run: "hooks/test.ts", }, - createFile: "hooks/test.ts", - expectedLanguage: language.ScriptLanguageTypeScript, - isShellLanguage: false, + createFile: "hooks/test.ts", + expectedKind: language.HookKindTypeScript, + isShell: false, }, { name: "InferDotNetFromExtension", @@ -209,9 +224,9 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { Name: "test", Run: "hooks/run.cs", }, - createFile: "hooks/run.cs", - expectedLanguage: language.ScriptLanguageDotNet, - isShellLanguage: false, + createFile: "hooks/run.cs", + expectedKind: language.HookKindDotNet, + isShell: false, }, { name: "InferBashFromExtension", @@ -219,9 +234,9 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { Name: "test", Run: "hooks/deploy.sh", }, - createFile: "hooks/deploy.sh", - expectedLanguage: language.ScriptLanguageBash, - isShellLanguage: true, + createFile: "hooks/deploy.sh", + expectedKind: language.HookKindBash, + isShell: true, }, { name: "InferPowerShellFromExtension", @@ -229,9 +244,9 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { Name: "test", Run: "hooks/deploy.ps1", }, - createFile: "hooks/deploy.ps1", - expectedLanguage: language.ScriptLanguagePowerShell, - isShellLanguage: true, + createFile: "hooks/deploy.ps1", + expectedKind: language.HookKindPowerShell, + isShell: true, }, { name: "InlineScriptDefaultsToOSShell", @@ -239,15 +254,15 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { Name: "test", Run: "echo hello", }, - expectedLanguage: defaultLanguageForOS(), - isShellLanguage: true, + expectedKind: defaultKindForOS(), + isShell: true, }, { - name: "InlineScriptWithLanguagePythonErrors", + name: "InlineScriptWithKindPythonErrors", config: HookConfig{ - Name: "test", - Language: language.ScriptLanguagePython, - Run: "print('hello')", + Name: "test", + Kind: language.HookKindPython, + Run: "print('hello')", }, expectError: "inline scripts are not supported " + "for python hooks", @@ -256,11 +271,11 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { name: "InlineScriptWithShellBashIsOK", config: HookConfig{ Name: "test", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "echo hello", }, - expectedLanguage: language.ScriptLanguageBash, - isShellLanguage: true, + expectedKind: language.HookKindBash, + isShell: true, }, } @@ -288,11 +303,11 @@ func TestHookConfig_ValidateLanguageResolution(t *testing.T) { require.NoError(t, err) require.Equal( - t, tt.expectedLanguage, config.Language, + t, tt.expectedKind, config.Kind, ) require.Equal( - t, tt.isShellLanguage, - config.Language.IsShellLanguage(), + t, tt.isShell, + config.Kind.IsShell(), ) }) } @@ -346,7 +361,7 @@ func TestHookConfig_ValidateDirInference(t *testing.T) { name: "ShellHookDirUnchanged", config: HookConfig{ Name: "test", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: filepath.Join("hooks", "setup.sh"), }, createFile: filepath.Join("hooks", "setup.sh"), @@ -356,7 +371,7 @@ func TestHookConfig_ValidateDirInference(t *testing.T) { name: "InlineScriptDirUnchanged", config: HookConfig{ Name: "test", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: "echo hello", }, expectedDir: "", @@ -384,26 +399,26 @@ func TestHookConfig_ValidateDirInference(t *testing.T) { } } -func TestScriptLanguage_IsShellLanguage(t *testing.T) { +func TestHookKind_IsShell(t *testing.T) { tests := []struct { name string - lang language.ScriptLanguage + kind language.HookKind expected bool }{ - {"Python", language.ScriptLanguagePython, false}, - {"JavaScript", language.ScriptLanguageJavaScript, false}, - {"TypeScript", language.ScriptLanguageTypeScript, false}, - {"DotNet", language.ScriptLanguageDotNet, false}, - {"Bash", language.ScriptLanguageBash, true}, - {"PowerShell", language.ScriptLanguagePowerShell, true}, - {"Unknown", language.ScriptLanguageUnknown, false}, + {"Python", language.HookKindPython, false}, + {"JavaScript", language.HookKindJavaScript, false}, + {"TypeScript", language.HookKindTypeScript, false}, + {"DotNet", language.HookKindDotNet, false}, + {"Bash", language.HookKindBash, true}, + {"PowerShell", language.HookKindPowerShell, true}, + {"Unknown", language.HookKindUnknown, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { require.Equal( t, tt.expected, - tt.lang.IsShellLanguage(), + tt.kind.IsShell(), ) }) } diff --git a/cli/azd/pkg/ext/python_hooks_e2e_test.go b/cli/azd/pkg/ext/python_hooks_e2e_test.go index 8892b0ebb6c..e3676336d5f 100644 --- a/cli/azd/pkg/ext/python_hooks_e2e_test.go +++ b/cli/azd/pkg/ext/python_hooks_e2e_test.go @@ -161,9 +161,9 @@ func TestPythonHook_AutoDetectFromExtension(t *testing.T) { // Verify the config was resolved as a non-shell language hook. hookCfg := hooksMap["predeploy"][0] assert.Equal( - t, language.ScriptLanguagePython, hookCfg.Language, + t, language.HookKindPython, hookCfg.Kind, ) - assert.False(t, hookCfg.Language.IsShellLanguage()) + assert.False(t, hookCfg.Kind.IsShell()) } // TestPythonHook_ExplicitLanguage verifies that language: python @@ -179,9 +179,9 @@ func TestPythonHook_ExplicitLanguage(t *testing.T) { hooksMap := map[string][]*HookConfig{ "predeploy": {{ - Name: "predeploy", - Language: language.ScriptLanguagePython, - Run: scriptRel, + Name: "predeploy", + Kind: language.HookKindPython, + Run: scriptRel, }}, } @@ -614,7 +614,7 @@ func TestPythonHook_ShellHookUnaffected(t *testing.T) { hooksMap := map[string][]*HookConfig{ "prebuild": {{ Name: "prebuild", - Shell: string(language.ScriptLanguageBash), + Shell: string(language.HookKindBash), Run: filepath.Join( "hooks", "prebuild.sh", ), @@ -699,7 +699,7 @@ func TestPythonHook_ExecutionPipeline(t *testing.T) { tests := []struct { name string scriptRel string - language language.ScriptLanguage + kind language.HookKind continueOnError bool exitCode int execErr error @@ -715,7 +715,7 @@ func TestPythonHook_ExecutionPipeline(t *testing.T) { { name: "SuccessExplicitLanguage", scriptRel: filepath.Join("hooks", "run"), - language: language.ScriptLanguagePython, + kind: language.HookKindPython, exitCode: 0, wantErr: false, }, @@ -752,8 +752,8 @@ func TestPythonHook_ExecutionPipeline(t *testing.T) { Run: tt.scriptRel, ContinueOnError: tt.continueOnError, } - if tt.language != language.ScriptLanguageUnknown { - hookCfg.Language = tt.language + if tt.kind != language.HookKindUnknown { + hookCfg.Kind = tt.kind } hooksMap := map[string][]*HookConfig{ @@ -938,9 +938,9 @@ func TestPythonHook_InlineScriptRejected(t *testing.T) { hooksMap := map[string][]*HookConfig{ "predeploy": {{ - Name: "predeploy", - Language: language.ScriptLanguagePython, - Run: "print('hello')", + Name: "predeploy", + Kind: language.HookKindPython, + Run: "print('hello')", }}, } diff --git a/cli/azd/pkg/tools/language/executor.go b/cli/azd/pkg/tools/language/executor.go index 4e94548609a..4b3f45165bc 100644 --- a/cli/azd/pkg/tools/language/executor.go +++ b/cli/azd/pkg/tools/language/executor.go @@ -8,71 +8,71 @@ import ( "strings" ) -// ScriptLanguage identifies the programming language of a hook script. -// The string value matches the token users write in the "language" field +// HookKind identifies the executor type of a hook script. +// The string value matches the token users write in the "kind" field // of azure.yaml hook configurations. -type ScriptLanguage string +type HookKind string const ( - // ScriptLanguageUnknown indicates the language could not be + // HookKindUnknown indicates the kind 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). + HookKindUnknown HookKind = "" + // HookKindBash identifies Bash shell scripts (.sh files). + HookKindBash HookKind = "sh" + // HookKindPowerShell identifies PowerShell scripts (.ps1 files). + HookKindPowerShell HookKind = "pwsh" + // HookKindJavaScript identifies JavaScript scripts (.js files). // Not yet supported — IoC resolution will fail with a descriptive error. - ScriptLanguageJavaScript ScriptLanguage = "js" - // ScriptLanguageTypeScript identifies TypeScript scripts (.ts files). + HookKindJavaScript HookKind = "js" + // HookKindTypeScript identifies TypeScript scripts (.ts files). // Not yet supported — IoC resolution will fail with a descriptive error. - ScriptLanguageTypeScript ScriptLanguage = "ts" - // ScriptLanguagePython identifies Python scripts (.py files). - ScriptLanguagePython ScriptLanguage = "python" - // ScriptLanguageDotNet identifies .NET (C#) scripts (.cs files). + HookKindTypeScript HookKind = "ts" + // HookKindPython identifies Python scripts (.py files). + HookKindPython HookKind = "python" + // HookKindDotNet identifies .NET (C#) scripts (.cs files). // Not yet supported — IoC resolution will fail with a descriptive error. - ScriptLanguageDotNet ScriptLanguage = "dotnet" + HookKindDotNet HookKind = "dotnet" ) -// IsShellLanguage reports whether lang is one of the built-in shell -// languages (Bash or PowerShell). Shell hooks support inline scripts -// and are executed directly by the OS shell. Non-shell languages +// IsShell reports whether k is one of the built-in shell +// kinds (Bash or PowerShell). Shell hooks support inline scripts +// and are executed directly by the OS shell. Non-shell kinds // (Python, JS, TS, DotNet) require a file on disk and are executed // through dedicated [tools.HookExecutor] implementations. -func (lang ScriptLanguage) IsShellLanguage() bool { - return lang == ScriptLanguageBash || - lang == ScriptLanguagePowerShell +func (k HookKind) IsShell() bool { + return k == HookKindBash || + k == HookKindPowerShell } -// InferLanguageFromPath determines the [ScriptLanguage] from the +// InferKindFromPath determines the [HookKind] 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] +// - .py → [HookKindPython] +// - .js → [HookKindJavaScript] +// - .ts → [HookKindTypeScript] +// - .cs → [HookKindDotNet] +// - .sh → [HookKindBash] +// - .ps1 → [HookKindPowerShell] // -// Returns [ScriptLanguageUnknown] for unrecognized extensions. -func InferLanguageFromPath(path string) ScriptLanguage { +// Returns [HookKindUnknown] for unrecognized extensions. +func InferKindFromPath(path string) HookKind { ext := strings.ToLower(filepath.Ext(path)) switch ext { case ".py": - return ScriptLanguagePython + return HookKindPython case ".js": - return ScriptLanguageJavaScript + return HookKindJavaScript case ".ts": - return ScriptLanguageTypeScript + return HookKindTypeScript case ".cs": - return ScriptLanguageDotNet + return HookKindDotNet case ".sh": - return ScriptLanguageBash + return HookKindBash case ".ps1": - return ScriptLanguagePowerShell + return HookKindPowerShell default: - return ScriptLanguageUnknown + return HookKindUnknown } } diff --git a/cli/azd/pkg/tools/language/executor_test.go b/cli/azd/pkg/tools/language/executor_test.go index 21a09b49f18..dfbdea2ea6e 100644 --- a/cli/azd/pkg/tools/language/executor_test.go +++ b/cli/azd/pkg/tools/language/executor_test.go @@ -9,87 +9,87 @@ import ( "github.com/stretchr/testify/assert" ) -func TestInferLanguageFromPath(t *testing.T) { +func TestInferKindFromPath(t *testing.T) { tests := []struct { name string path string - expected ScriptLanguage + expected HookKind }{ { name: "Python", path: "hooks/pre-deploy.py", - expected: ScriptLanguagePython, + expected: HookKindPython, }, { name: "JavaScript", path: "hooks/pre-deploy.js", - expected: ScriptLanguageJavaScript, + expected: HookKindJavaScript, }, { name: "TypeScript", path: "hooks/pre-deploy.ts", - expected: ScriptLanguageTypeScript, + expected: HookKindTypeScript, }, { name: "DotNet", path: "hooks/pre-deploy.cs", - expected: ScriptLanguageDotNet, + expected: HookKindDotNet, }, { name: "Bash", path: "hooks/pre-deploy.sh", - expected: ScriptLanguageBash, + expected: HookKindBash, }, { name: "PowerShell", path: "hooks/pre-deploy.ps1", - expected: ScriptLanguagePowerShell, + expected: HookKindPowerShell, }, { name: "UnknownTxt", path: "hooks/readme.txt", - expected: ScriptLanguageUnknown, + expected: HookKindUnknown, }, { name: "UnknownGo", path: "hooks/main.go", - expected: ScriptLanguageUnknown, + expected: HookKindUnknown, }, { name: "NoExtension", path: "hooks/Makefile", - expected: ScriptLanguageUnknown, + expected: HookKindUnknown, }, { name: "EmptyPath", path: "", - expected: ScriptLanguageUnknown, + expected: HookKindUnknown, }, { name: "CaseInsensitivePY", path: "hooks/deploy.PY", - expected: ScriptLanguagePython, + expected: HookKindPython, }, { name: "CaseInsensitiveJs", path: "hooks/deploy.Js", - expected: ScriptLanguageJavaScript, + expected: HookKindJavaScript, }, { name: "CaseInsensitivePS1", path: "hooks/deploy.PS1", - expected: ScriptLanguagePowerShell, + expected: HookKindPowerShell, }, { name: "MultipleDots", path: "hooks/pre.deploy.py", - expected: ScriptLanguagePython, + expected: HookKindPython, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := InferLanguageFromPath(tt.path) + result := InferKindFromPath(tt.path) assert.Equal(t, tt.expected, result) }) } diff --git a/cli/azd/pkg/tools/language/project_discovery.go b/cli/azd/pkg/tools/language/project_discovery.go index 26ca1f4d748..5a5caf40cdc 100644 --- a/cli/azd/pkg/tools/language/project_discovery.go +++ b/cli/azd/pkg/tools/language/project_discovery.go @@ -21,15 +21,15 @@ type ProjectContext struct { // 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 + // Language is the hook kind inferred from the project file. + Language HookKind } // 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" + Name string // exact filename or glob pattern + Language HookKind // inferred hook kind + IsGlob bool // true for patterns like "*.*proj" } // knownProjectFiles defines project files to search for, in priority order. @@ -43,10 +43,10 @@ type projectFileEntry struct { // DOWN a tree to detect service projects. Hook discovery walks UP from a // script to find the nearest project context. var knownProjectFiles = []projectFileEntry{ - {Name: "pyproject.toml", Language: ScriptLanguagePython}, - {Name: "requirements.txt", Language: ScriptLanguagePython}, - {Name: "package.json", Language: ScriptLanguageJavaScript}, - {Name: "*.*proj", Language: ScriptLanguageDotNet, IsGlob: true}, + {Name: "pyproject.toml", Language: HookKindPython}, + {Name: "requirements.txt", Language: HookKindPython}, + {Name: "package.json", Language: HookKindJavaScript}, + {Name: "*.*proj", Language: HookKindDotNet, IsGlob: true}, } // DiscoverProjectFile walks up the directory tree from the directory diff --git a/cli/azd/pkg/tools/language/project_discovery_test.go b/cli/azd/pkg/tools/language/project_discovery_test.go index eddbf8a2a81..615463efc21 100644 --- a/cli/azd/pkg/tools/language/project_discovery_test.go +++ b/cli/azd/pkg/tools/language/project_discovery_test.go @@ -25,7 +25,7 @@ func TestDiscoverProjectFile_Python(t *testing.T) { require.NotNil(t, result) assert.Equal(t, dir, result.ProjectDir) assert.Equal(t, projectFile, result.DependencyFile) - assert.Equal(t, ScriptLanguagePython, result.Language) + assert.Equal(t, HookKindPython, result.Language) } func TestDiscoverProjectFile_PythonPyproject(t *testing.T) { @@ -41,7 +41,7 @@ func TestDiscoverProjectFile_PythonPyproject(t *testing.T) { require.NotNil(t, result) assert.Equal(t, dir, result.ProjectDir) assert.Equal(t, projectFile, result.DependencyFile) - assert.Equal(t, ScriptLanguagePython, result.Language) + assert.Equal(t, HookKindPython, result.Language) } func TestDiscoverProjectFile_JavaScript(t *testing.T) { @@ -57,7 +57,7 @@ func TestDiscoverProjectFile_JavaScript(t *testing.T) { require.NotNil(t, result) assert.Equal(t, dir, result.ProjectDir) assert.Equal(t, projectFile, result.DependencyFile) - assert.Equal(t, ScriptLanguageJavaScript, result.Language) + assert.Equal(t, HookKindJavaScript, result.Language) } func TestDiscoverProjectFile_DotNet(t *testing.T) { @@ -73,7 +73,7 @@ func TestDiscoverProjectFile_DotNet(t *testing.T) { require.NotNil(t, result) assert.Equal(t, dir, result.ProjectDir) assert.Equal(t, projectFile, result.DependencyFile) - assert.Equal(t, ScriptLanguageDotNet, result.Language) + assert.Equal(t, HookKindDotNet, result.Language) } func TestDiscoverProjectFile_DotNetFsProj(t *testing.T) { @@ -89,7 +89,7 @@ func TestDiscoverProjectFile_DotNetFsProj(t *testing.T) { require.NotNil(t, result) assert.Equal(t, dir, result.ProjectDir) assert.Equal(t, projectFile, result.DependencyFile) - assert.Equal(t, ScriptLanguageDotNet, result.Language) + assert.Equal(t, HookKindDotNet, result.Language) } func TestDiscoverProjectFile_WalkUp(t *testing.T) { @@ -114,7 +114,7 @@ func TestDiscoverProjectFile_WalkUp(t *testing.T) { require.NotNil(t, result) assert.Equal(t, dir, result.ProjectDir) assert.Equal(t, projectFile, result.DependencyFile) - assert.Equal(t, ScriptLanguagePython, result.Language) + assert.Equal(t, HookKindPython, result.Language) } func TestDiscoverProjectFile_BoundaryRespected(t *testing.T) { @@ -165,7 +165,7 @@ func TestDiscoverProjectFile_Priority(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - assert.Equal(t, ScriptLanguagePython, result.Language, + assert.Equal(t, HookKindPython, result.Language, "requirements.txt has higher priority than package.json") assert.Equal(t, reqFile, result.DependencyFile) } @@ -185,7 +185,7 @@ func TestDiscoverProjectFile_PyprojectOverRequirements(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - assert.Equal(t, ScriptLanguagePython, result.Language) + assert.Equal(t, HookKindPython, result.Language) assert.Equal(t, pyprojectFile, result.DependencyFile, "pyproject.toml should win over requirements.txt (PEP 621)") } @@ -213,7 +213,7 @@ func TestDiscoverProjectFile_WalkUpMultipleLevels(t *testing.T) { require.NotNil(t, result) assert.Equal(t, root, result.ProjectDir) assert.Equal(t, projectFile, result.DependencyFile) - assert.Equal(t, ScriptLanguageJavaScript, result.Language) + assert.Equal(t, HookKindJavaScript, result.Language) } func TestDiscoverProjectFile_ClosestWins(t *testing.T) { @@ -240,7 +240,7 @@ func TestDiscoverProjectFile_ClosestWins(t *testing.T) { require.NotNil(t, result) assert.Equal(t, child, result.ProjectDir) assert.Equal(t, closerFile, result.DependencyFile) - assert.Equal(t, ScriptLanguageJavaScript, result.Language, + assert.Equal(t, HookKindJavaScript, result.Language, "closer package.json should win over farther requirements.txt") } diff --git a/cli/azd/pkg/tools/language/python_executor.go b/cli/azd/pkg/tools/language/python_executor.go index beba7d4c21a..2f8d55ec322 100644 --- a/cli/azd/pkg/tools/language/python_executor.go +++ b/cli/azd/pkg/tools/language/python_executor.go @@ -120,7 +120,7 @@ func (e *pythonExecutor) Prepare( // exists and the venv lives inside projectDir (so // the python CLI can locate the activation script). if projCtx != nil && - projCtx.Language == ScriptLanguagePython { + projCtx.Language == HookKindPython { if name, ok := relativeVenvName( projCtx.ProjectDir, existing, ); ok { @@ -147,7 +147,7 @@ func (e *pythonExecutor) Prepare( // Skip venv setup if the discovered project is not // Python (e.g. a package.json for JS living near the // Python script). - if projCtx.Language != ScriptLanguagePython { + if projCtx.Language != HookKindPython { return nil } diff --git a/cli/azd/test/mocks/mocktools/hook_executors.go b/cli/azd/test/mocks/mocktools/hook_executors.go index cfbd55f6ef5..381515bb7ab 100644 --- a/cli/azd/test/mocks/mocktools/hook_executors.go +++ b/cli/azd/test/mocks/mocktools/hook_executors.go @@ -16,13 +16,13 @@ import ( // in tests. func RegisterHookExecutors(mockCtx *mocks.MockContext) { mockCtx.Container.MustRegisterNamedTransient( - string(language.ScriptLanguageBash), bash.NewExecutor, + string(language.HookKindBash), bash.NewExecutor, ) mockCtx.Container.MustRegisterNamedTransient( - string(language.ScriptLanguagePowerShell), powershell.NewExecutor, + string(language.HookKindPowerShell), powershell.NewExecutor, ) mockCtx.Container.MustRegisterSingleton(python.NewCli) mockCtx.Container.MustRegisterNamedTransient( - string(language.ScriptLanguagePython), language.NewPythonExecutor, + string(language.HookKindPython), language.NewPythonExecutor, ) } From 3f1017b60331b24904c5bd3d0882800725d03af5 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 7 Apr 2026 11:31:24 -0700 Subject: [PATCH 15/25] fix: add 'venvs' to cspell overrides for hooks_runner.go Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/.vscode/cspell.yaml | 754 ++++++++++++++++++------------------ 1 file changed, 387 insertions(+), 367 deletions(-) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 5d1c0f6fbcd..14a0586b269 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -1,367 +1,387 @@ -import: ../../../.vscode/cspell.global.yaml -words: - - agentcopilot - - agentdetect - - Authenticode - - gofmt - - golangci - - toplevel - - azcloud - - azdext - - azurefd - - azcontainerregistry - - CGNAT - - backoff - - Canonicalize - - Chans - - chinacloudapi - - cmds - - Codespace - - Codespaces - - codesign - - cooldown - - customtype - - devcontainers - - errgroup - - errorhandler - - extendee - - eiannone - # CDN host name - - gfgac2cmf7b8cuay - - Getenv - - goversioninfo - - OPENCODE - - opencode - - grpcbroker - - msiexec - - nosec - - oneof - - idxs - # Looks like the protogen has a spelling error for panics - - pancis - - Paren - - OIDCREQUESTURI - - pkgux - - ppid - - PPID - - proto - - protobuf - - protoc - - protoimpl - - protojson - - protoreflect - - SNAPPROCESS - - structpb - - syncmap - - syscall - - Retryable - - runcontext - - surveyterm - - Toolhelp - - unmarshals - - unmarshaling - - unsetting - - usgovcloudapi - - worktrees - - jdbc - - springframework - - santhosh - - tekuri - - jsonschema - - rustc - - figspec - - finetune - - gitdir - - bubbletea - - lipgloss - - gopxl - - subcmd - - genproto - - errdetails - - slogger - - SSRF - - ssrf - - Teredo - - allowlist - - blocklist - - metacharacter - - metacharacters - - yarnpkg - - azconfig - - hostnames - - managedhsm - - microsoftazure - - seekable - - seekability - - APFS - - NTFS - - mcpgo - - cpus - - unsanitized - - PATHEXT - - mintty - - dockerenv - - dedup - - exfiltration - - Fprintf - - gocritic - - gradlew - - IMDS - - managedhsm - - microsoftazure - - myhost - - newtag - - preconfigured - - Println - - sctx - - secretname - - secretversion - - TTLs - - vaultname -languageSettings: - - languageId: go - ignoreRegExpList: - - /mock.*/ -dictionaryDefinitions: - - name: azdProjectDictionary - path: ./cspell-azd-dictionary.txt - description: Custom Workspace Dictionary - addWords: true - scope: workspace -dictionaries: - - azdProjectDictionary -overrides: - - filename: cmd/mcp.go - words: - - internalcmd - - filename: cmd/extension.go - words: - - compatResult - - compatCopy - - filename: pkg/azdext/config_helper.go - words: - - myext - - filename: internal/tracing/fields/domains.go - words: - - azmk - - azurecontainerapps - - azureedge - - azurefd - - cloudapp - - mediaservices - - msecnd - - filename: docs/docgen.go - words: - - alexwolf - - alexwolfmsft - - pname - - puichan - - devx - - azdevcli - - azdeveloper - - hannahhunter - - hhunter - - filename: pkg/tools/python/python.go - words: - - venv - - filename: pkg/spin/run.go - words: - - errored - - filename: pkg/project/project_utils.go - words: - - __pycache__ - - Venv - - filename: pkg/project/project_test.go - words: - - Aapi - - Aweb - - Aworker - - azdtempl - - filename: pkg/executil/executil_windows.go - words: - - hndl - - JOBOBJECT - - filename: pkg/environment/azdcontext/azdcontext.go - words: - - ents - - unmarshalled - - filename: pkg/exec/util_test.go - words: - - keychain - - crusername - - azurecr - - filename: pkg/tools/dotnet/dotnet.go - words: - - PWORD - - filename: pkg/tools/kubectl/kubectl.go - words: - - tmpl - - filename: pkg/experimentation/assignment.go - words: - - assignmentcache - - machineid - - azdversion - - filename: pkg/experimentation/client.go - words: - - variantassignment - - filename: pkg/azsdk/storage/storage_blob_client.go - words: - - azblob - - filename: pkg/project/service_target_aks.go - words: - - kustomization - - templating - - filename: pkg/project/scaffold_gen.go - words: - - GUNICORN - - MULTIWORKERS - - filename: pkg/extensions/command_resolver.go - words: - - intarray - - stringarray - - filename: CHANGELOG.md - words: - - aistudio - - apiserver - - azdevify - - Chdir - - creds - - Moby - - upsert - - filename: docs/proxy-configuration.md - words: - - Telerik - - filename: test/recording/recording.go - words: - - oidctoken - - filename: test/recording/proxy.go - words: - - WSAECONNRESET - - filename: test/functional/testdata/samples/funcapp/getting_started.md - words: - - funcignore - - filename: internal/vsrpc/handler.go - words: - - arity - - filename: test/internal/tfoidc/main.go - words: - - tfoidc - - OIDCREQUESTURI - - oidctoken - - filename: CHANGELOG.md - words: - - GHSA - - gjrc - - filename: pkg/apphost/generate.go - words: - - alue - - filename: docs/extensions/extension-framework.md - words: - - getenv - - errorf - - println - - myext - - Fatalf - - filename: pkg/project/dockerfile_builder.go - words: - - WORKDIR - - workdir - - filename: docs/style-guidelines/new-azd-command.md - words: - - pflag - - struct - - Errorf - - Sprintf - - mycommand - - omitempty - - Fprintf - - tabwriter - - azdcontext - - azapi - - eastus - - filename: cmd/cmd_help.go - words: - - cmdinternal - - filename: .github/copilot-instructions.md - words: - - golines - - technicalterm - - Errorf - - filename: internal/mcp/tools/azd_yaml_schema.go - words: - - santhosh - - tekuri - - jsonschema - - filename: internal/mcp/host.go - words: - - Streamable - - filename: cmd/completion.go - words: - - compinit - - filename: pkg/project/service_target_dotnet_containerapp.go - words: - - IMAGENAME - - filename: internal/vsrpc/server.go - words: - - CSWSH - - filename: pkg/extensions/manager.go - words: - - myext - - filename: docs/recording-functional-tests-guide.md - words: - - httptest - - Logf - - Getenv - - httptest - - filename: docs/extensions/extension-framework.md - words: - - invopop - - filename: docs/style-guidelines/azd-style-guide.md - words: - - camelCase - - checkmark - - Colorization - - gofmt - - myService - - NewMyService - - PascalCase - - SomeDependency - - testify - - userConfig - - Println - - adrz - - rvwxu - - filename: pkg/extensions/metadata.go - words: - - invopop - - filename: pkg/extensions/schema_validator.go - words: - - invopop - - jsonschemav - - filename: pkg/project/dockerfile_builder.go - words: - - WORKDIR - - workdir - - filename: pkg/azdext/scope_detector.go - words: - - fakeazure - - filename: "{pkg/azapi/permissions.go,pkg/infra/provisioning/bicep/bicep_provider.go}" - words: - - ABAC - - filename: pkg/infra/provisioning/bicep/local_preflight.go - words: - - actioned - - filename: pkg/project/project.go - words: - - copylocks - - filename: pkg/ext/models.go - words: - - unvalidated - - filename: docs/code-coverage-guide.md - words: - - covdata - - GOWORK -ignorePaths: - - "**/*_test.go" - - "**/mock*.go" - - "**/*.pb.go" +import: ../../../.vscode/cspell.global.yaml +words: + - agentcopilot + - agentdetect + - Authenticode + - gofmt + - golangci + - toplevel + - azcloud + - azdext + - azurefd + - azcontainerregistry + - CGNAT + - backoff + - Canonicalize + - Chans + - chinacloudapi + - cmds + - Codespace + - Codespaces + - codesign + - cooldown + - customtype + - devcontainers + - errgroup + - errorhandler + - extendee + - eiannone + # CDN host name + - gfgac2cmf7b8cuay + - Getenv + - goversioninfo + - OPENCODE + - opencode + - grpcbroker + - msiexec + - nosec + - oneof + - idxs + # Looks like the protogen has a spelling error for panics + - pancis + - Paren + - OIDCREQUESTURI + - pkgux + - ppid + - PPID + - proto + - protobuf + - protoc + - protoimpl + - protojson + - protoreflect + - SNAPPROCESS + - structpb + - syncmap + - syscall + - Retryable + - runcontext + - surveyterm + - Toolhelp + - unmarshals + - unmarshaling + - unsetting + - usgovcloudapi + - worktrees + - jdbc + - springframework + - santhosh + - tekuri + - jsonschema + - rustc + - figspec + - finetune + - gitdir + - bubbletea + - lipgloss + - gopxl + - subcmd + - genproto + - errdetails + - slogger + - SSRF + - ssrf + - Teredo + - allowlist + - blocklist + - metacharacter + - metacharacters + - yarnpkg + - azconfig + - hostnames + - managedhsm + - microsoftazure + - seekable + - seekability + - APFS + - NTFS + - mcpgo + - cpus + - unsanitized + - PATHEXT + - mintty + - dockerenv + - dedup + - exfiltration + - Fprintf + - gocritic + - gradlew + - IMDS + - managedhsm + - microsoftazure + - myhost + - newtag + - preconfigured + - Println + - sctx + - secretname + - secretversion + - TTLs + - vaultname +languageSettings: + - languageId: go + ignoreRegExpList: + - /mock.*/ +dictionaryDefinitions: + - name: azdProjectDictionary + path: ./cspell-azd-dictionary.txt + description: Custom Workspace Dictionary + addWords: true + scope: workspace +dictionaries: + - azdProjectDictionary +overrides: + - filename: cmd/mcp.go + words: + - internalcmd + - filename: cmd/extension.go + words: + - compatResult + - compatCopy + - filename: pkg/azdext/config_helper.go + words: + - myext + - filename: internal/tracing/fields/domains.go + words: + - azmk + - azurecontainerapps + - azureedge + - azurefd + - cloudapp + - mediaservices + - msecnd + - filename: docs/docgen.go + words: + - alexwolf + - alexwolfmsft + - pname + - puichan + - devx + - azdevcli + - azdeveloper + - hannahhunter + - hhunter + - filename: pkg/tools/python/python.go + words: + - venv + - filename: pkg/spin/run.go + words: + - errored + - filename: pkg/project/project_utils.go + words: + - __pycache__ + - Venv + - filename: pkg/project/project_test.go + words: + - Aapi + - Aweb + - Aworker + - azdtempl + - filename: pkg/executil/executil_windows.go + words: + - hndl + - JOBOBJECT + - filename: pkg/environment/azdcontext/azdcontext.go + words: + - ents + - unmarshalled + - filename: pkg/exec/util_test.go + words: + - keychain + - crusername + - azurecr + - filename: pkg/tools/dotnet/dotnet.go + words: + - PWORD + - filename: pkg/tools/kubectl/kubectl.go + words: + - tmpl + - filename: pkg/experimentation/assignment.go + words: + - assignmentcache + - machineid + - azdversion + - filename: pkg/experimentation/client.go + words: + - variantassignment + - filename: pkg/azsdk/storage/storage_blob_client.go + words: + - azblob + - filename: pkg/project/service_target_aks.go + words: + - kustomization + - templating + - filename: pkg/project/scaffold_gen.go + words: + - GUNICORN + - MULTIWORKERS + - filename: pkg/extensions/command_resolver.go + words: + - intarray + - stringarray + - filename: CHANGELOG.md + words: + - aistudio + - apiserver + - azdevify + - Chdir + - creds + - Moby + - upsert + - filename: docs/proxy-configuration.md + words: + - Telerik + - filename: test/recording/recording.go + words: + - oidctoken + - filename: test/recording/proxy.go + words: + - WSAECONNRESET + - filename: test/functional/testdata/samples/funcapp/getting_started.md + words: + - funcignore + - filename: internal/vsrpc/handler.go + words: + - arity + - filename: test/internal/tfoidc/main.go + words: + - tfoidc + - OIDCREQUESTURI + - oidctoken + - filename: CHANGELOG.md + words: + - GHSA + - gjrc + - filename: pkg/apphost/generate.go + words: + - alue + - filename: docs/extensions/extension-framework.md + words: + - getenv + - errorf + - println + - myext + - Fatalf + - filename: extensions/microsoft.azd.ai.builder/internal/cmd/start.go + words: + - dall + - datasource + - vectorizing + - filename: pkg/project/dockerfile_builder.go + words: + - WORKDIR + - workdir + - filename: docs/style-guidelines/new-azd-command.md + words: + - pflag + - struct + - Errorf + - Sprintf + - mycommand + - omitempty + - Fprintf + - tabwriter + - azdcontext + - azapi + - eastus + - filename: cmd/cmd_help.go + words: + - cmdinternal + - filename: .github/copilot-instructions.md + words: + - golines + - technicalterm + - Errorf + - filename: internal/mcp/tools/azd_yaml_schema.go + words: + - santhosh + - tekuri + - jsonschema + - filename: internal/mcp/host.go + words: + - Streamable + - filename: cmd/completion.go + words: + - compinit + - filename: pkg/project/service_target_dotnet_containerapp.go + words: + - IMAGENAME + - filename: internal/vsrpc/server.go + words: + - CSWSH + - filename: pkg/extensions/manager.go + words: + - myext + - filename: extensions/microsoft.azd.extensions/internal/resources/languages/**/.gitignore + words: + - rsuser + - userosscache + - docstates + - dylib + - filename: docs/recording-functional-tests-guide.md + words: + - httptest + - Logf + - Getenv + - httptest + - filename: docs/extensions/extension-framework.md + words: + - invopop + - filename: docs/style-guidelines/azd-style-guide.md + words: + - camelCase + - checkmark + - Colorization + - gofmt + - myService + - NewMyService + - PascalCase + - SomeDependency + - testify + - userConfig + - Println + - adrz + - rvwxu + - filename: pkg/extensions/metadata.go + words: + - invopop + - filename: pkg/extensions/schema_validator.go + words: + - invopop + - jsonschemav + - filename: pkg/project/dockerfile_builder.go + words: + - WORKDIR + - workdir + - filename: extensions/microsoft.azd.demo/internal/cmd/metadata.go + words: + - invopop + - filename: extensions/microsoft.azd.concurx/internal/cmd/prompt_model.go + words: + - textinput + - filename: pkg/azdext/scope_detector.go + words: + - fakeazure + - filename: "{pkg/azapi/permissions.go,pkg/infra/provisioning/bicep/bicep_provider.go}" + words: + - ABAC + - filename: extensions/azure.ai.models/internal/cmd/custom_create.go + words: + - Qwen + - filename: pkg/infra/provisioning/bicep/local_preflight.go + words: + - actioned + - filename: pkg/ext/models.go + words: + - unvalidated + - filename: docs/code-coverage-guide.md + words: + - covdata + - GOWORK + - filename: pkg/ext/hooks_runner.go + words: + - venvs +ignorePaths: + - "**/*_test.go" + - "**/mock*.go" + - "**/*.pb.go" From 1763fde0b2be7d2009d04f35a4e17a88fd40fd3d Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 7 Apr 2026 11:56:00 -0700 Subject: [PATCH 16/25] fix: remove unused shellToKind function (golangci-lint unused) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shellToKind helper in pkg/ext/models.go had zero callers after the refactoring — validate() maps Shell to Kind via direct cast. Delete the dead code to fix the golangci-lint unused error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/ext/models.go | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/cli/azd/pkg/ext/models.go b/cli/azd/pkg/ext/models.go index 273c1446569..ad27ea95daa 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -279,17 +279,3 @@ func defaultKindForOS() language.HookKind { } return language.HookKindBash } - -// shellToKind maps a raw shell string (from the YAML "shell" -// field) to the corresponding [language.HookKind]. Returns -// [language.HookKindUnknown] for unrecognized values. -func shellToKind(shell string) language.HookKind { - switch shell { - case "sh": - return language.HookKindBash - case "pwsh": - return language.HookKindPowerShell - default: - return language.HookKindUnknown - } -} From 6f0d21bd5a0997acce084d26e662b22879dbd1a2 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 7 Apr 2026 12:00:57 -0700 Subject: [PATCH 17/25] fix: update project_config_coverage3_test.go to use language.HookKindBash Replace removed ext.ShellTypeBash references with string(language.HookKindBash) after rebase onto main. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/project/project_config_coverage3_test.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cli/azd/pkg/project/project_config_coverage3_test.go b/cli/azd/pkg/project/project_config_coverage3_test.go index dc3ea13b960..a7708b6d9c7 100644 --- a/cli/azd/pkg/project/project_config_coverage3_test.go +++ b/cli/azd/pkg/project/project_config_coverage3_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/azure/azure-dev/cli/azd/pkg/ext" + "github.com/azure/azure-dev/cli/azd/pkg/tools/language" "github.com/braydonk/yaml" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -88,7 +89,7 @@ func Test_HooksConfig_MarshalYAML_Empty(t *testing.T) { func Test_HooksConfig_MarshalYAML_SingleHook(t *testing.T) { hooks := HooksConfig{ "preprovision": { - {Run: "echo hello", Shell: ext.ShellTypeBash}, + {Run: "echo hello", Shell: string(language.HookKindBash)}, }, } result, err := hooks.MarshalYAML() @@ -104,8 +105,8 @@ func Test_HooksConfig_MarshalYAML_SingleHook(t *testing.T) { func Test_HooksConfig_MarshalYAML_MultipleHooks(t *testing.T) { hooks := HooksConfig{ "preprovision": { - {Run: "echo step1", Shell: ext.ShellTypeBash}, - {Run: "echo step2", Shell: ext.ShellTypeBash}, + {Run: "echo step1", Shell: string(language.HookKindBash)}, + {Run: "echo step2", Shell: string(language.HookKindBash)}, }, } result, err := hooks.MarshalYAML() @@ -122,10 +123,10 @@ func Test_HooksConfig_RoundTrip(t *testing.T) { // Test round trip with all single hooks (marshals as map[string]*HookConfig, legacy unmarshal works) original := HooksConfig{ "preprovision": { - {Run: "echo hello", Shell: ext.ShellTypeBash}, + {Run: "echo hello", Shell: string(language.HookKindBash)}, }, "postprovision": { - {Run: "echo bye", Shell: ext.ShellTypeBash}, + {Run: "echo bye", Shell: string(language.HookKindBash)}, }, } From 0e56a27e3dc71f02f2f8682958b09f71d242edd7 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 7 Apr 2026 13:00:04 -0700 Subject: [PATCH 18/25] fix: cross-platform path handling in hook validation Use OS-converted paths consistently in validate() to prevent Linux/Mac failures. The fullCheckPath was using unconverted hc.Run in filepath.Join() instead of the already-converted relativeCheckPath. The same bug existed in both models.go (validate) and hooks_manager.go (ValidateHooks). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/ext/hooks_manager.go | 2 +- cli/azd/pkg/ext/models.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/azd/pkg/ext/hooks_manager.go b/cli/azd/pkg/ext/hooks_manager.go index 6479f9791a7..281157b6793 100644 --- a/cli/azd/pkg/ext/hooks_manager.go +++ b/cli/azd/pkg/ext/hooks_manager.go @@ -170,7 +170,7 @@ func (h *HooksManager) ValidateHooks(ctx context.Context, allHooks map[string][] relativeCheckPath := strings.ReplaceAll(hookConfig.Run, "/", string(os.PathSeparator)) fullCheckPath := relativeCheckPath if hookConfig.cwd != "" { - fullCheckPath = filepath.Join(hookConfig.cwd, hookConfig.Run) + fullCheckPath = filepath.Join(hookConfig.cwd, relativeCheckPath) } _, err := os.Stat(fullCheckPath) diff --git a/cli/azd/pkg/ext/models.go b/cli/azd/pkg/ext/models.go index ad27ea95daa..e1b016a34f7 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -143,7 +143,7 @@ func (hc *HookConfig) validate() error { ) fullCheckPath := relativeCheckPath if hc.cwd != "" { - fullCheckPath = filepath.Join(hc.cwd, hc.Run) + fullCheckPath = filepath.Join(hc.cwd, relativeCheckPath) } stats, err := os.Stat(fullCheckPath) From 7adae7e10199d9091b5b7bc6154357344055f8ff Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 7 Apr 2026 14:27:16 -0700 Subject: [PATCH 19/25] fix: set execute permission on inline temp scripts for Linux/Mac Temp .sh and .ps1 files created by bash/powershell executors during Prepare() need execute permission on Unix systems. os.CreateTemp() creates files with 0600 mode, and os.WriteFile() does not change permissions on existing files. An explicit os.Chmod() is required. Windows ignores Unix file permissions, so this only manifests on Linux/Mac CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/tools/bash/bash.go | 11 +++++ cli/azd/pkg/tools/bash/bash_test.go | 47 +++++++++++++++++++ cli/azd/pkg/tools/powershell/powershell.go | 11 +++++ .../pkg/tools/powershell/powershell_test.go | 44 +++++++++++++++++ 4 files changed, 113 insertions(+) diff --git a/cli/azd/pkg/tools/bash/bash.go b/cli/azd/pkg/tools/bash/bash.go index f49269af6ab..ef813f0ebad 100644 --- a/cli/azd/pkg/tools/bash/bash.go +++ b/cli/azd/pkg/tools/bash/bash.go @@ -53,6 +53,17 @@ func (b *bashExecutor) Prepare( return fmt.Errorf("writing temp script: %w", err) } + // os.WriteFile only applies perm when *creating* a new file. + // The file already exists from CreateTemp (mode 0600), so we + // must explicitly chmod to add execute permission for Unix. + if err := os.Chmod( + tmpFile.Name(), osutil.PermissionExecutableFile, + ); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + return fmt.Errorf("setting temp script permissions: %w", err) + } + tmpFile.Close() b.tempFile = tmpFile.Name() diff --git a/cli/azd/pkg/tools/bash/bash_test.go b/cli/azd/pkg/tools/bash/bash_test.go index ffa9b25acd9..d15a4f21caa 100644 --- a/cli/azd/pkg/tools/bash/bash_test.go +++ b/cli/azd/pkg/tools/bash/bash_test.go @@ -6,10 +6,13 @@ package bash import ( "context" "errors" + "os" "runtime" + "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/pkg/tools" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/stretchr/testify/require" @@ -31,6 +34,50 @@ func Test_Bash_Execute(t *testing.T) { require.NoError(t, err) }) + t.Run("PrepareInlineScript", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + executor := NewExecutor(mockContext.CommandRunner) + + execCtx := tools.ExecutionContext{ + Cwd: workingDir, + EnvVars: env, + HookName: "predeploy", + InlineScript: "echo hello", + } + err := executor.Prepare( + *mockContext.Context, scriptPath, execCtx, + ) + require.NoError(t, err) + + // Verify temp file was created with correct content. + be := executor.(*bashExecutor) + require.NotEmpty(t, be.tempFile) + content, err := os.ReadFile(be.tempFile) + require.NoError(t, err) + require.True( + t, + strings.HasPrefix(string(content), "#!/bin/sh"), + ) + require.Contains(t, string(content), "echo hello") + + // Verify execute permission is set (Unix only; + // Windows does not enforce the execute bit). + if runtime.GOOS != "windows" { + info, err := os.Stat(be.tempFile) + require.NoError(t, err) + require.Equal( + t, + osutil.PermissionExecutableFile, + info.Mode().Perm(), + ) + } + + // Cleanup should remove the file. + require.NoError(t, executor.Cleanup(*mockContext.Context)) + _, err = os.Stat(be.tempFile) + require.True(t, os.IsNotExist(err)) + }) + t.Run("Success", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) diff --git a/cli/azd/pkg/tools/powershell/powershell.go b/cli/azd/pkg/tools/powershell/powershell.go index d111ae6b749..eb58fc989cb 100644 --- a/cli/azd/pkg/tools/powershell/powershell.go +++ b/cli/azd/pkg/tools/powershell/powershell.go @@ -66,6 +66,17 @@ func (p *powershellExecutor) Prepare( return fmt.Errorf("writing temp script: %w", err) } + // os.WriteFile only applies perm when *creating* a new file. + // The file already exists from CreateTemp (mode 0600), so we + // must explicitly chmod to add execute permission for Unix. + if err := os.Chmod( + tmpFile.Name(), osutil.PermissionExecutableFile, + ); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + return fmt.Errorf("setting temp script permissions: %w", err) + } + tmpFile.Close() p.tempFile = tmpFile.Name() } diff --git a/cli/azd/pkg/tools/powershell/powershell_test.go b/cli/azd/pkg/tools/powershell/powershell_test.go index af0f14c32ee..beb3d64bce3 100644 --- a/cli/azd/pkg/tools/powershell/powershell_test.go +++ b/cli/azd/pkg/tools/powershell/powershell_test.go @@ -7,11 +7,13 @@ import ( "context" "errors" "fmt" + "os" "runtime" "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/osutil" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/stretchr/testify/require" @@ -64,6 +66,48 @@ func Test_Powershell_Prepare(t *testing.T) { require.Contains(t, sugErr.Suggestion, "powershell/scripting/install") } }) + + t.Run("PrepareInlineScript", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + mockContext.CommandRunner.MockToolInPath("pwsh", nil) + + execCtx := tools.ExecutionContext{ + HookName: "predeploy", + InlineScript: "Write-Host 'hello'", + } + ps := NewExecutor(mockContext.CommandRunner) + err := ps.Prepare(*mockContext.Context, "script.ps1", execCtx) + require.NoError(t, err) + + // Verify temp file was created with correct content. + pe := ps.(*powershellExecutor) + require.NotEmpty(t, pe.tempFile) + content, err := os.ReadFile(pe.tempFile) + require.NoError(t, err) + require.Contains( + t, string(content), "ErrorActionPreference", + ) + require.Contains( + t, string(content), "Write-Host 'hello'", + ) + + // Verify execute permission is set (Unix only; + // Windows does not enforce the execute bit). + if runtime.GOOS != "windows" { + info, err := os.Stat(pe.tempFile) + require.NoError(t, err) + require.Equal( + t, + osutil.PermissionExecutableFile, + info.Mode().Perm(), + ) + } + + // Cleanup should remove the file. + require.NoError(t, ps.Cleanup(*mockContext.Context)) + _, err = os.Stat(pe.tempFile) + require.True(t, os.IsNotExist(err)) + }) } func Test_Powershell_Execute(t *testing.T) { From ff8e0da57dc57c1d99a0493f9159fc22633b93b4 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 7 Apr 2026 15:34:03 -0700 Subject: [PATCH 20/25] fix: resolve run paths relative to dir when dir is explicitly set When dir is set in hook config, the run path is now resolved relative to dir for file existence checks, with a fallback to the project root. Previously, run was always resolved relative to the project root, causing configs like: run: main.py dir: hooks/preprovision to fail because os.Stat checked projectRoot/main.py instead of projectRoot/hooks/preprovision/main.py. A new dirExplicit field tracks whether Dir was user-configured vs auto-inferred, ensuring execHook builds the correct absolute script path for the executor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/ext/hooks_runner.go | 18 +- cli/azd/pkg/ext/hooks_runner_test.go | 297 +++++++++++++++++++++++++++ cli/azd/pkg/ext/models.go | 52 ++++- cli/azd/pkg/ext/models_test.go | 180 ++++++++++++++++ 4 files changed, 545 insertions(+), 2 deletions(-) diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index d6f4504ddd6..1621a3a4561 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -245,7 +245,23 @@ func (h *HooksRunner) execHook( // consistent, fully-qualified path regardless of hook language. scriptPath := hookConfig.path if hookConfig.path != "" { - scriptPath = filepath.Join(boundaryDir, hookConfig.path) + if hookConfig.dirExplicit && + !filepath.IsAbs(hookConfig.path) { + // When Dir was explicitly set by the user, the + // run path is relative to Dir — resolve through + // Dir, not boundaryDir alone. + dir := hookConfig.Dir + if !filepath.IsAbs(dir) { + dir = filepath.Join(boundaryDir, dir) + } + scriptPath = filepath.Join( + dir, hookConfig.path, + ) + } else { + scriptPath = filepath.Join( + boundaryDir, hookConfig.path, + ) + } } // Prepare (unified — venv/deps for Python, pwsh detection for diff --git a/cli/azd/pkg/ext/hooks_runner_test.go b/cli/azd/pkg/ext/hooks_runner_test.go index 88058c0ce05..a97eb3cfae3 100644 --- a/cli/azd/pkg/ext/hooks_runner_test.go +++ b/cli/azd/pkg/ext/hooks_runner_test.go @@ -758,3 +758,300 @@ func envSliceToMap(envVars []string) map[string]string { } return m } + +// Test_ExecHook_DirRunResolution verifies that when Dir is set, the +// script path passed to executors is resolved through Dir. +func Test_ExecHook_DirRunResolution(t *testing.T) { + t.Run("PythonHookWithDir", func(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + env := environment.NewWithValues("test", map[string]string{}) + + // Create hooks/preprovision/main.py (no requirements.txt + // to avoid triggering venv setup in the executor). + hookDir := filepath.Join( + cwd, "hooks", "preprovision", + ) + require.NoError( + t, + os.MkdirAll(hookDir, osutil.PermissionDirectory), + ) + require.NoError(t, os.WriteFile( + filepath.Join(hookDir, "main.py"), + nil, osutil.PermissionExecutableFile, + )) + + hooksMap := map[string][]*HookConfig{ + "preprovision": { + { + Name: "preprovision", + Kind: language.HookKindPython, + Run: "main.py", + Dir: filepath.Join("hooks", "preprovision"), + }, + }, + } + + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) + + var capturedScriptPath string + var capturedCwd string + + mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + + // Mock Python version check. + 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 + }, + ) + + // Mock the actual script execution and capture paths. + mockContext.CommandRunner.When( + func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "main.py") + }, + ).RespondFn( + func(args exec.RunArgs) (exec.RunResult, error) { + capturedCwd = args.Cwd + // The script path is the last argument + // passed to the python command. + for _, arg := range args.Args { + if strings.Contains(arg, "main.py") { + capturedScriptPath = arg + } + } + 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, + "provision", + ) + + require.NoError(t, err) + + // The script path should be resolved through Dir. + expectedScript := filepath.Join( + cwd, "hooks", "preprovision", "main.py", + ) + require.Equal( + t, expectedScript, capturedScriptPath, + "script path should resolve through Dir", + ) + + // The cwd should be the Dir directory. + expectedCwd := filepath.Join( + cwd, "hooks", "preprovision", + ) + require.Equal( + t, expectedCwd, capturedCwd, + "cwd should be the Dir directory", + ) + }) + + t.Run("ShellHookWithDir", func(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + env := environment.NewWithValues("test", map[string]string{}) + + // Create scripts/deploy.sh. + scriptDir := filepath.Join(cwd, "scripts") + require.NoError( + t, + os.MkdirAll(scriptDir, osutil.PermissionDirectory), + ) + require.NoError(t, os.WriteFile( + filepath.Join(scriptDir, "deploy.sh"), + nil, osutil.PermissionExecutableFile, + )) + + hooksMap := map[string][]*HookConfig{ + "predeploy": { + { + Name: "predeploy", + Shell: string(language.HookKindBash), + Run: "deploy.sh", + Dir: "scripts", + }, + }, + } + + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) + + var capturedScriptArg string + shellRan := false + + mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + + mockContext.CommandRunner.When( + func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "deploy.sh") + }, + ).RespondFn( + func(args exec.RunArgs) (exec.RunResult, error) { + shellRan = true + if len(args.Args) > 0 { + capturedScriptArg = args.Args[0] + } + 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) + + // The script path should resolve through Dir. + expectedScript := filepath.Join( + cwd, "scripts", "deploy.sh", + ) + require.Equal( + t, + filepath.ToSlash(expectedScript), + capturedScriptArg, + "shell script path should resolve through Dir", + ) + }) + + t.Run("NoDirResolvesFromRoot", func(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + env := environment.NewWithValues("test", map[string]string{}) + + // Create hooks/preprovision/main.py at the root. + hookDir := filepath.Join( + cwd, "hooks", "preprovision", + ) + require.NoError( + t, + os.MkdirAll(hookDir, osutil.PermissionDirectory), + ) + require.NoError(t, os.WriteFile( + filepath.Join(hookDir, "main.py"), + nil, osutil.PermissionExecutableFile, + )) + + hooksMap := map[string][]*HookConfig{ + "preprovision": { + { + Name: "preprovision", + Kind: language.HookKindPython, + Run: filepath.Join( + "hooks", "preprovision", "main.py", + ), + }, + }, + } + + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) + + var capturedScriptPath string + + mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + + 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 + }, + ) + + mockContext.CommandRunner.When( + func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "main.py") + }, + ).RespondFn( + func(args exec.RunArgs) (exec.RunResult, error) { + for _, arg := range args.Args { + if strings.Contains(arg, "main.py") { + capturedScriptPath = arg + } + } + 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, + "provision", + ) + + require.NoError(t, err) + + // Without Dir, the full relative path is joined to cwd. + expectedScript := filepath.Join( + cwd, "hooks", "preprovision", "main.py", + ) + require.Equal( + t, expectedScript, capturedScriptPath, + "without Dir, path resolves from project root", + ) + }) +} diff --git a/cli/azd/pkg/ext/models.go b/cli/azd/pkg/ext/models.go index e1b016a34f7..71076e0d553 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -76,6 +76,9 @@ type HookConfig struct { script string // Indicates if the shell was automatically detected based on OS (used for warnings) usingDefaultShell bool + // dirExplicit is true when the user explicitly set Dir in the + // YAML configuration (not auto-inferred from the run path). + dirExplicit bool // Internal name of the hook running for a given command Name string `yaml:",omitempty"` @@ -138,19 +141,66 @@ func (hc *HookConfig) validate() error { return ErrRunRequired } + // Record whether Dir was explicitly set by the user before + // any auto-inference fills it in. + hc.dirExplicit = hc.Dir != "" + relativeCheckPath := strings.ReplaceAll( hc.Run, "/", string(os.PathSeparator), ) + + // Resolve the full path for the os.Stat check. When Dir is + // explicitly set and run is relative, we try Dir first and + // fall back to cwd (project root). This supports both: + // run: main.py + dir: hooks/preprovision + // run: hooks/deploy.py + dir: custom_workdir fullCheckPath := relativeCheckPath + foundViaDir := false if hc.cwd != "" { - fullCheckPath = filepath.Join(hc.cwd, relativeCheckPath) + if filepath.IsAbs(relativeCheckPath) { + // Absolute run paths are used as-is — Dir and + // cwd are not prepended. + fullCheckPath = relativeCheckPath + } else if hc.dirExplicit { + // First try: resolve relative to Dir. + dir := hc.Dir + if !filepath.IsAbs(dir) { + dir = filepath.Join(hc.cwd, dir) + } + dirPath := filepath.Join( + dir, relativeCheckPath, + ) + info, dirErr := os.Stat(dirPath) + if dirErr == nil && !info.IsDir() { + fullCheckPath = dirPath + foundViaDir = true + } else { + // Fall back: resolve relative to cwd. + fullCheckPath = filepath.Join( + hc.cwd, relativeCheckPath, + ) + } + } else { + fullCheckPath = filepath.Join( + hc.cwd, relativeCheckPath, + ) + } } stats, err := os.Stat(fullCheckPath) if err == nil && !stats.IsDir() { hc.path = relativeCheckPath + if foundViaDir { + hc.dirExplicit = true + } } else { hc.script = hc.Run + // If Dir was set but the file was not found via Dir, + // clear dirExplicit so execHook does not try to join + // Dir + path. + if hc.dirExplicit { + hc.dirExplicit = false + } } // Kind resolution — priority: diff --git a/cli/azd/pkg/ext/models_test.go b/cli/azd/pkg/ext/models_test.go index 5a8e0e0909e..ed6c4cc9dc9 100644 --- a/cli/azd/pkg/ext/models_test.go +++ b/cli/azd/pkg/ext/models_test.go @@ -399,6 +399,186 @@ func TestHookConfig_ValidateDirInference(t *testing.T) { } } +// TestHookConfig_ValidateDirRunResolution verifies that when Dir is +// explicitly set, the run path is resolved relative to Dir (not the +// project root) for file existence checks. +func TestHookConfig_ValidateDirRunResolution(t *testing.T) { + tests := []struct { + name string + config HookConfig + createFiles []string // paths relative to cwd + expectPath string // expected hc.path (empty = inline) + expectScript string // expected hc.script (empty = file) + expectError string + }{ + { + name: "DirWithRunResolvesRelativeToDir", + config: HookConfig{ + Name: "preprovision", + Kind: language.HookKindPython, + Run: "main.py", + Dir: filepath.Join("hooks", "preprovision"), + }, + createFiles: []string{ + filepath.Join( + "hooks", "preprovision", "main.py", + ), + }, + expectPath: "main.py", + expectScript: "", + }, + { + name: "DirWithSubdirInRun", + config: HookConfig{ + Name: "prebuild", + Kind: language.HookKindPython, + Run: filepath.Join("src", "main.py"), + Dir: filepath.Join("hooks", "preprovision"), + }, + createFiles: []string{ + filepath.Join( + "hooks", "preprovision", + "src", "main.py", + ), + }, + expectPath: filepath.Join("src", "main.py"), + expectScript: "", + }, + { + name: "NoDirRunFullPathFromRoot", + config: HookConfig{ + Name: "predeploy", + Kind: language.HookKindPython, + Run: filepath.Join( + "hooks", "preprovision", "main.py", + ), + }, + createFiles: []string{ + filepath.Join( + "hooks", "preprovision", "main.py", + ), + }, + expectPath: filepath.Join( + "hooks", "preprovision", "main.py", + ), + expectScript: "", + }, + { + name: "DirSetFileDoesNotExistNonShell", + config: HookConfig{ + Name: "preprovision", + Kind: language.HookKindPython, + Run: "nonexistent.py", + Dir: filepath.Join("hooks", "preprovision"), + }, + createFiles: []string{}, // no files + expectError: "inline scripts are not supported " + + "for python hooks", + }, + { + name: "ShellHookWithDir", + config: HookConfig{ + Name: "predeploy", + Shell: string(language.HookKindBash), + Run: "deploy.sh", + Dir: "scripts", + }, + createFiles: []string{ + filepath.Join("scripts", "deploy.sh"), + }, + expectPath: "deploy.sh", + expectScript: "", + }, + { + name: "AbsoluteRunIgnoresDir", + config: HookConfig{ + Name: "test", + Kind: language.HookKindPython, + Run: "main.py", + Dir: filepath.Join("hooks", "preprovision"), + }, + // Put file in dir but also at root — the + // absolute path case is tested next; here we + // verify the dir path is used. + createFiles: []string{ + filepath.Join( + "hooks", "preprovision", "main.py", + ), + }, + expectPath: "main.py", + expectScript: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := tt.config + cwd := t.TempDir() + config.cwd = cwd + + for _, f := range tt.createFiles { + fp := filepath.Join(cwd, f) + err := os.MkdirAll( + filepath.Dir(fp), 0o755, + ) + require.NoError(t, err) + err = os.WriteFile(fp, nil, 0o600) + require.NoError(t, err) + } + + err := config.validate() + + if tt.expectError != "" { + require.Error(t, err) + require.Contains( + t, err.Error(), tt.expectError, + ) + return + } + + require.NoError(t, err) + require.Equal( + t, tt.expectPath, config.path, + "path mismatch", + ) + require.Equal( + t, tt.expectScript, config.script, + "script mismatch", + ) + }) + } +} + +// TestHookConfig_ValidateDirRunAbsolutePath verifies that an +// absolute run path is not joined with Dir. +func TestHookConfig_ValidateDirRunAbsolutePath(t *testing.T) { + cwd := t.TempDir() + + // Create an "absolute" script inside a temp location. + absScript := filepath.Join(cwd, "abs-scripts", "run.py") + require.NoError( + t, + os.MkdirAll(filepath.Dir(absScript), 0o755), + ) + require.NoError( + t, os.WriteFile(absScript, nil, 0o600), + ) + + config := HookConfig{ + Name: "test", + Kind: language.HookKindPython, + Run: absScript, + Dir: filepath.Join("hooks", "preprovision"), + cwd: cwd, + } + + err := config.validate() + require.NoError(t, err) + // Absolute run paths are resolved without Dir prefix. + require.Equal(t, absScript, config.path) + require.Equal(t, "", config.script) +} + func TestHookKind_IsShell(t *testing.T) { tests := []struct { name string From 30a875122dd3b6d4bf6eaf2d5ed74a677355173b Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 7 Apr 2026 15:51:29 -0700 Subject: [PATCH 21/25] refactor: resolve paths once in validate(), eliminate dirExplicit Replace the overloaded dirExplicit flag with properly resolved path fields (resolvedScriptPath, resolvedDir) set during validate(). This eliminates path resolution duplication between validate() and execHook(), and moves the path traversal check to fail-fast during validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/ext/hooks_runner.go | 76 ++------------ cli/azd/pkg/ext/models.go | 127 +++++++++++++++++------ cli/azd/pkg/ext/models_test.go | 178 ++++++++++++++++++++++++++++++++ 3 files changed, 283 insertions(+), 98 deletions(-) diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index 1621a3a4561..1c13d58fb62 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -8,7 +8,6 @@ import ( "fmt" "log" "os" - "path/filepath" "strings" "github.com/azure/azure-dev/cli/azd/pkg/environment" @@ -17,7 +16,6 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/ioc" "github.com/azure/azure-dev/cli/azd/pkg/keyvault" - "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/tools" ) @@ -149,55 +147,24 @@ func (h *HooksRunner) execHook( } } - // validate() resolves the hook's language, path, and shell type. + // validate() resolves the hook's language, path, shell type, + // and computes resolvedDir / resolvedScriptPath. if err := hookConfig.validate(); err != nil { return err } - // Determine the boundary directory for project file discovery. - boundaryDir := h.cwd - if hookConfig.cwd != "" { - boundaryDir = hookConfig.cwd + // Use pre-resolved paths from validate(). + cwd := hookConfig.resolvedDir + if cwd == "" { + cwd = h.cwd // fallback (shouldn't happen after validate) } - // Determine working directory from Dir (set explicitly or - // auto-inferred from the run path by validate). - cwd := h.cwd - if hookConfig.Dir != "" { - dir := hookConfig.Dir - if !filepath.IsAbs(dir) { - dir = filepath.Join(boundaryDir, dir) - } - cwd = dir - } else if hookConfig.path != "" && !hookConfig.Kind.IsShell() { - // Non-shell languages (Python, JS/TS, .NET) derive cwd from - // the script's directory so that project-relative tooling - // (venvs, node_modules) resolves correctly. Shell hooks - // retain the original behavior: cwd = project root. - cwd = filepath.Dir( - filepath.Join(boundaryDir, hookConfig.path), - ) + boundaryDir := hookConfig.cwd + if boundaryDir == "" { + boundaryDir = h.cwd } - // Validate that cwd is contained within boundaryDir to - // prevent path traversal via ".." in the dir config field. - absDir, err := filepath.Abs(cwd) - if err != nil { - return fmt.Errorf("resolving hook directory: %w", err) - } - absBoundary, err := filepath.Abs(boundaryDir) - if err != nil { - return fmt.Errorf( - "resolving boundary directory: %w", err, - ) - } - if !osutil.IsPathContained(absBoundary, absDir) { - return fmt.Errorf( - "hook directory %q escapes project root %q", - cwd, boundaryDir, - ) - } - cwd = absDir + scriptPath := hookConfig.resolvedScriptPath envVars := hookEnv.Environ() @@ -241,29 +208,6 @@ func (h *HooksRunner) execHook( } } - // Resolve script path to absolute so every executor receives a - // consistent, fully-qualified path regardless of hook language. - scriptPath := hookConfig.path - if hookConfig.path != "" { - if hookConfig.dirExplicit && - !filepath.IsAbs(hookConfig.path) { - // When Dir was explicitly set by the user, the - // run path is relative to Dir — resolve through - // Dir, not boundaryDir alone. - dir := hookConfig.Dir - if !filepath.IsAbs(dir) { - dir = filepath.Join(boundaryDir, dir) - } - scriptPath = filepath.Join( - dir, hookConfig.path, - ) - } else { - scriptPath = filepath.Join( - boundaryDir, hookConfig.path, - ) - } - } - // Prepare (unified — venv/deps for Python, pwsh detection for // PowerShell, inline temp file creation for Bash/PowerShell hooks). log.Printf( diff --git a/cli/azd/pkg/ext/models.go b/cli/azd/pkg/ext/models.go index 71076e0d553..d5f5de34ab4 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -11,6 +11,7 @@ import ( "runtime" "strings" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/tools/language" ) @@ -76,9 +77,12 @@ type HookConfig struct { script string // Indicates if the shell was automatically detected based on OS (used for warnings) usingDefaultShell bool - // dirExplicit is true when the user explicitly set Dir in the - // YAML configuration (not auto-inferred from the run path). - dirExplicit bool + // resolvedScriptPath is the absolute path to the script file, + // computed during validate(). Empty for inline scripts. + resolvedScriptPath string + // resolvedDir is the absolute working directory for hook + // execution, computed during validate(). + resolvedDir string // Internal name of the hook running for a given command Name string `yaml:",omitempty"` @@ -141,9 +145,9 @@ func (hc *HookConfig) validate() error { return ErrRunRequired } - // Record whether Dir was explicitly set by the user before - // any auto-inference fills it in. - hc.dirExplicit = hc.Dir != "" + // Record whether Dir was explicitly provided by the user + // before any auto-inference fills it in. + dirExplicit := hc.Dir != "" relativeCheckPath := strings.ReplaceAll( hc.Run, "/", string(os.PathSeparator), @@ -158,11 +162,8 @@ func (hc *HookConfig) validate() error { foundViaDir := false if hc.cwd != "" { if filepath.IsAbs(relativeCheckPath) { - // Absolute run paths are used as-is — Dir and - // cwd are not prepended. fullCheckPath = relativeCheckPath - } else if hc.dirExplicit { - // First try: resolve relative to Dir. + } else if dirExplicit { dir := hc.Dir if !filepath.IsAbs(dir) { dir = filepath.Join(hc.cwd, dir) @@ -175,7 +176,6 @@ func (hc *HookConfig) validate() error { fullCheckPath = dirPath foundViaDir = true } else { - // Fall back: resolve relative to cwd. fullCheckPath = filepath.Join( hc.cwd, relativeCheckPath, ) @@ -191,16 +191,11 @@ func (hc *HookConfig) validate() error { if err == nil && !stats.IsDir() { hc.path = relativeCheckPath if foundViaDir { - hc.dirExplicit = true + dirExplicit = true } } else { hc.script = hc.Run - // If Dir was set but the file was not found via Dir, - // clear dirExplicit so execHook does not try to join - // Dir + path. - if hc.dirExplicit { - hc.dirExplicit = false - } + dirExplicit = false } // Kind resolution — priority: @@ -240,27 +235,95 @@ func (hc *HookConfig) validate() error { if hc.Dir == "" && hc.path != "" { hc.Dir = filepath.Dir(hc.path) } - hc.validated = true - return nil + } else { + // For inline scripts with no resolved kind, default to + // the OS-appropriate shell kind. + if hc.Kind == language.HookKindUnknown && + hc.script != "" { + hc.Kind = defaultKindForOS() + hc.usingDefaultShell = true + } + + // For file-based hooks with an unrecognized extension. + if hc.Kind == language.HookKindUnknown { + return fmt.Errorf( + "script with file extension '%s' is not "+ + "valid. %w.", + filepath.Ext(hc.path), + ErrUnsupportedScriptType, + ) + } + } + + // ── Resolve absolute paths ────────────────────────────── + // After this point, resolvedDir and resolvedScriptPath are + // the single source of truth for hook execution paths. + // execHook() should read these directly. + + boundaryDir := hc.cwd + if boundaryDir == "" { + boundaryDir, _ = os.Getwd() + } + + // Resolve working directory. + if hc.Dir != "" { + dir := hc.Dir + if !filepath.IsAbs(dir) { + dir = filepath.Join(boundaryDir, dir) + } + hc.resolvedDir = dir + } else if hc.path != "" && !hc.Kind.IsShell() { + // Non-shell hooks: derive cwd from script directory so + // project-relative tooling (venvs, node_modules) works. + hc.resolvedDir = filepath.Dir( + filepath.Join(boundaryDir, hc.path), + ) + } else { + hc.resolvedDir = boundaryDir } - // For inline scripts with no resolved kind, default to the - // OS-appropriate shell kind. - if hc.Kind == language.HookKindUnknown && - hc.script != "" { - hc.Kind = defaultKindForOS() - hc.usingDefaultShell = true + // Resolve script path to absolute. + if hc.path != "" { + if dirExplicit && !filepath.IsAbs(hc.path) { + // Dir was explicitly set — run path is relative + // to Dir, not boundaryDir alone. + dir := hc.Dir + if !filepath.IsAbs(dir) { + dir = filepath.Join(boundaryDir, dir) + } + hc.resolvedScriptPath = filepath.Join( + dir, hc.path, + ) + } else if !filepath.IsAbs(hc.path) { + hc.resolvedScriptPath = filepath.Join( + boundaryDir, hc.path, + ) + } else { + hc.resolvedScriptPath = hc.path + } } - // For file-based hooks with an unrecognized extension, error. - if hc.Kind == language.HookKindUnknown { + // Validate that resolvedDir stays within the project root + // to prevent path traversal via ".." in Dir. + absDir, absErr := filepath.Abs(hc.resolvedDir) + if absErr != nil { + return fmt.Errorf( + "resolving hook directory: %w", absErr, + ) + } + absBoundary, absErr := filepath.Abs(boundaryDir) + if absErr != nil { + return fmt.Errorf( + "resolving boundary directory: %w", absErr, + ) + } + if !osutil.IsPathContained(absBoundary, absDir) { return fmt.Errorf( - "script with file extension '%s' is not valid. "+ - "%w.", - filepath.Ext(hc.path), - ErrUnsupportedScriptType, + "hook directory %q escapes project root %q", + hc.Dir, boundaryDir, ) } + hc.resolvedDir = absDir hc.validated = true diff --git a/cli/azd/pkg/ext/models_test.go b/cli/azd/pkg/ext/models_test.go index ed6c4cc9dc9..2fd8fd482e4 100644 --- a/cli/azd/pkg/ext/models_test.go +++ b/cli/azd/pkg/ext/models_test.go @@ -603,3 +603,181 @@ func TestHookKind_IsShell(t *testing.T) { }) } } + +// TestHookConfig_ResolvedPaths verifies that validate() populates +// resolvedScriptPath and resolvedDir correctly for various +// combinations of Dir, run path, and hook kind. +func TestHookConfig_ResolvedPaths(t *testing.T) { + tests := []struct { + name string + config HookConfig + createFiles []string + expectedDir string // suffix of resolvedDir (checked via HasSuffix) + expectedScriptPath string // suffix of resolvedScriptPath + }{ + { + name: "PythonWithExplicitDir", + config: HookConfig{ + Name: "test", + Kind: language.HookKindPython, + Run: "main.py", + Dir: filepath.Join("hooks", "preprovision"), + }, + createFiles: []string{ + filepath.Join( + "hooks", "preprovision", "main.py", + ), + }, + expectedDir: filepath.Join( + "hooks", "preprovision", + ), + expectedScriptPath: filepath.Join( + "hooks", "preprovision", "main.py", + ), + }, + { + name: "PythonWithInferredDir", + config: HookConfig{ + Name: "test", + Kind: language.HookKindPython, + Run: filepath.Join( + "hooks", "preprovision", "main.py", + ), + }, + createFiles: []string{ + filepath.Join( + "hooks", "preprovision", "main.py", + ), + }, + expectedDir: filepath.Join( + "hooks", "preprovision", + ), + expectedScriptPath: filepath.Join( + "hooks", "preprovision", "main.py", + ), + }, + { + name: "ShellHookWithDir", + config: HookConfig{ + Name: "test", + Shell: string(language.HookKindBash), + Run: "deploy.sh", + Dir: "scripts", + }, + createFiles: []string{ + filepath.Join("scripts", "deploy.sh"), + }, + expectedDir: "scripts", + expectedScriptPath: filepath.Join( + "scripts", "deploy.sh", + ), + }, + { + name: "ShellHookNoDirUsesProjectRoot", + config: HookConfig{ + Name: "test", + Shell: string(language.HookKindBash), + Run: filepath.Join("scripts", "deploy.sh"), + }, + createFiles: []string{ + filepath.Join("scripts", "deploy.sh"), + }, + expectedDir: "", // resolvedDir == cwd + expectedScriptPath: filepath.Join( + "scripts", "deploy.sh", + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := tt.config + cwd := t.TempDir() + config.cwd = cwd + + for _, f := range tt.createFiles { + fp := filepath.Join(cwd, f) + require.NoError( + t, + os.MkdirAll( + filepath.Dir(fp), 0o755, + ), + ) + require.NoError( + t, + os.WriteFile(fp, nil, 0o600), + ) + } + + err := config.validate() + require.NoError(t, err) + + // resolvedDir should be an absolute path + // ending with the expected suffix. + require.True( + t, + filepath.IsAbs(config.resolvedDir), + "resolvedDir should be absolute: %s", + config.resolvedDir, + ) + + if tt.expectedDir == "" { + // Should resolve to cwd itself. + absCwd, _ := filepath.Abs(cwd) + require.Equal( + t, absCwd, config.resolvedDir, + ) + } else { + expectedFull := filepath.Join( + cwd, tt.expectedDir, + ) + absExpected, _ := filepath.Abs( + expectedFull, + ) + require.Equal( + t, absExpected, config.resolvedDir, + ) + } + + // resolvedScriptPath should be absolute and + // end with the expected suffix. + if tt.expectedScriptPath != "" { + require.True( + t, + filepath.IsAbs( + config.resolvedScriptPath, + ), + "resolvedScriptPath should be "+ + "absolute: %s", + config.resolvedScriptPath, + ) + expectedFull := filepath.Join( + cwd, tt.expectedScriptPath, + ) + require.Equal( + t, + expectedFull, + config.resolvedScriptPath, + ) + } + }) + } +} + +// TestHookConfig_ValidatePathTraversal verifies that validate() +// rejects Dir values that escape the project root via "..". +func TestHookConfig_ValidatePathTraversal(t *testing.T) { + cwd := t.TempDir() + + config := HookConfig{ + Name: "test", + Shell: string(language.HookKindBash), + Run: "echo hello", + Dir: filepath.Join("..", "..", "escape"), + cwd: cwd, + } + + err := config.validate() + require.Error(t, err) + require.Contains(t, err.Error(), "escapes project root") +} From 01cc5282f6ea5b864a9100273d50afed1f53c773 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 7 Apr 2026 15:57:13 -0700 Subject: [PATCH 22/25] fix: move venvs cspell override to models.go The word moved from hooks_runner.go to models.go during path resolution refactoring. Also added workdir override for the same reason. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/.vscode/cspell.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 14a0586b269..e40948d49d5 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -374,13 +374,12 @@ overrides: - filename: pkg/ext/models.go words: - unvalidated + - venvs + - workdir - filename: docs/code-coverage-guide.md words: - covdata - GOWORK - - filename: pkg/ext/hooks_runner.go - words: - - venvs ignorePaths: - "**/*_test.go" - "**/mock*.go" From 8162310c3e5ad69b5076487512b5cb30f0139bcb Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 7 Apr 2026 16:19:16 -0700 Subject: [PATCH 23/25] refactor: remove Language field, replace hardcoded strings with HookKind constants - Remove HookConfig.Language field (introduced and deprecated in same PR) - Only Kind and Shell remain as YAML inputs - Replace hardcoded "sh"/"pwsh" strings in tests with language.HookKindBash/HookKindPowerShell constants - Clean up "language" terminology in comments and error messages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/docs/language-hooks.md | 30 +++++++++---------- cli/azd/pkg/ext/hooks_manager.go | 9 +++--- cli/azd/pkg/ext/hooks_manager_test.go | 4 +-- cli/azd/pkg/ext/hooks_runner.go | 2 +- cli/azd/pkg/ext/hooks_runner_test.go | 12 ++++---- cli/azd/pkg/ext/models.go | 35 ++++++++-------------- cli/azd/pkg/ext/models_test.go | 17 ----------- cli/azd/pkg/ext/python_hooks_e2e_test.go | 18 +++++------ cli/azd/pkg/project/project_config_test.go | 13 ++++---- cli/azd/pkg/project/project_test.go | 13 ++++---- 10 files changed, 64 insertions(+), 89 deletions(-) diff --git a/cli/azd/docs/language-hooks.md b/cli/azd/docs/language-hooks.md index bfcb4911cb0..094c748273c 100644 --- a/cli/azd/docs/language-hooks.md +++ b/cli/azd/docs/language-hooks.md @@ -6,21 +6,21 @@ unified lifecycle regardless of its executor: **Prepare → Execute → Cleanup* ## Supported Executor Types -| Executor | `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 | +| Executor | `kind` 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 Hooks are configured in `azure.yaml` under the `hooks` section at the project or service level. Two optional fields are available: -### `language` (string, optional) +### `kind` (string, optional) Specifies the executor type for the hook. Allowed values: `sh`, `pwsh`, `js`, `ts`, `python`, `dotnet`. @@ -70,16 +70,16 @@ hooks: # dir is auto-inferred as hooks/preprovision/ ``` -### Python hook — explicit language +### Python hook — explicit kind When auto-detection is not desired or the file extension is ambiguous, set -the `language` field explicitly to select the Python executor: +the `kind` field explicitly to select the Python executor: ```yaml hooks: postprovision: run: ./hooks/setup.py - language: python + kind: python ``` ### Python hook with working directory override @@ -107,7 +107,7 @@ hooks: shell: pwsh posix: run: ./hooks/setup.py - language: python + kind: python ``` ### Python hook with secrets @@ -125,7 +125,7 @@ hooks: ### Bash hook (existing behavior, unchanged) -Bash hooks continue to work exactly as before. The `language` field is +Bash hooks continue to work exactly as before. The `kind` field is optional and defaults to the appropriate shell type: ```yaml @@ -141,7 +141,7 @@ Every hook follows the unified **Prepare → Execute → Cleanup** lifecycle: 1. **Prepare** — The executor validates prerequisites and performs any setup. This includes: - - **Language detection** from the explicit `language` field, the + - **Kind detection** from the explicit `kind` field, the `shell` field, or the file extension of the `run` path. - **Runtime validation** — verifying the required runtime is installed (e.g. Python 3 for `.py` hooks, pwsh for `.ps1`). diff --git a/cli/azd/pkg/ext/hooks_manager.go b/cli/azd/pkg/ext/hooks_manager.go index 281157b6793..5c41b1f3d6a 100644 --- a/cli/azd/pkg/ext/hooks_manager.go +++ b/cli/azd/pkg/ext/hooks_manager.go @@ -176,10 +176,9 @@ func (h *HooksManager) ValidateHooks(ctx context.Context, allHooks map[string][] _, err := os.Stat(fullCheckPath) isInlineScript := err != nil // File doesn't exist, so it's inline - // If no kind/language/shell and it's an inline script, set + // If no kind/shell and it's an inline script, set // OS default Kind for warning purposes. if hookConfig.Shell == "" && - hookConfig.Language == "" && hookConfig.Kind == language.HookKindUnknown && isInlineScript { hookConfig.Kind = defaultKindForOS() @@ -270,8 +269,8 @@ func (h *HooksManager) validateRuntimes( ) []HookWarning { var warnings []HookWarning - // Collect unique language runtimes required across all hooks. - // Track the first hook name per language for actionable messages. + // Collect unique non-shell hook kinds required across all hooks. + // Track the first hook name per kind for actionable messages. requiredLangs := map[language.HookKind]string{} for hookName, hookConfigs := range allHooks { @@ -300,7 +299,7 @@ func (h *HooksManager) validateRuntimes( cfg.Name = hookName } - // Run validate to resolve the Language field from + // Run validate to resolve the Kind field from // file extension / explicit config. if err := cfg.validate(); err != nil { // Validation errors are surfaced by GetAll / diff --git a/cli/azd/pkg/ext/hooks_manager_test.go b/cli/azd/pkg/ext/hooks_manager_test.go index e66d3ce19e4..0004ec8b859 100644 --- a/cli/azd/pkg/ext/hooks_manager_test.go +++ b/cli/azd/pkg/ext/hooks_manager_test.go @@ -195,7 +195,7 @@ func Test_ValidateHooks_PythonInstalled(t *testing.T) { ostest.Chdir(t, tempDir) // Create a Python script file so validate() resolves it - // as a language hook. + // as a non-shell hook. scriptDir := filepath.Join(tempDir, "hooks") require.NoError(t, os.MkdirAll(scriptDir, osutil.PermissionDirectory)) require.NoError(t, @@ -373,7 +373,7 @@ func Test_ValidateHooks_MixedHooks(t *testing.T) { mgr := NewHooksManager(tempDir, mockRunner) result := mgr.ValidateHooks(t.Context(), hooksMap) - // Exactly one language warning (Python), no shell warnings. + // Exactly one runtime warning (Python), no shell warnings. pythonWarnings := 0 for _, w := range result.Warnings { if strings.Contains(w.Message, "Python") { diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index 1c13d58fb62..a84b6117af9 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -147,7 +147,7 @@ func (h *HooksRunner) execHook( } } - // validate() resolves the hook's language, path, shell type, + // validate() resolves the hook's kind, path, shell type, // and computes resolvedDir / resolvedScriptPath. if err := hookConfig.validate(); err != nil { return err diff --git a/cli/azd/pkg/ext/hooks_runner_test.go b/cli/azd/pkg/ext/hooks_runner_test.go index a97eb3cfae3..0b85f90f923 100644 --- a/cli/azd/pkg/ext/hooks_runner_test.go +++ b/cli/azd/pkg/ext/hooks_runner_test.go @@ -425,10 +425,10 @@ func Test_Hooks_Validation(t *testing.T) { }) } -// Test_ExecHook_LanguageHooks verifies the integration between +// Test_ExecHook_NonShellHooks verifies the integration between // [HooksRunner] and [tools.HookExecutor] for non-shell hooks. -func Test_ExecHook_LanguageHooks(t *testing.T) { - t.Run("PythonLanguageHook", func(t *testing.T) { +func Test_ExecHook_NonShellHooks(t *testing.T) { + t.Run("PythonHook", func(t *testing.T) { cwd := t.TempDir() ostest.Chdir(t, cwd) @@ -553,7 +553,7 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { require.True(t, shellRan, "Bash executor should use absolute path for .sh hooks") }) - t.Run("LanguageHookPrepareFailure", func(t *testing.T) { + t.Run("NonShellHookPrepareFailure", func(t *testing.T) { cwd := t.TempDir() ostest.Chdir(t, cwd) @@ -609,7 +609,7 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { require.Contains(t, err.Error(), "preparing hook") }) - t.Run("LanguageHookExecuteFailure", func(t *testing.T) { + t.Run("NonShellHookExecuteFailure", func(t *testing.T) { cwd := t.TempDir() ostest.Chdir(t, cwd) @@ -672,7 +672,7 @@ func Test_ExecHook_LanguageHooks(t *testing.T) { require.Contains(t, err.Error(), "exit code: '1'") }) - t.Run("LanguageHookEnvVars", func(t *testing.T) { + t.Run("NonShellHookEnvVars", func(t *testing.T) { cwd := t.TempDir() ostest.Chdir(t, cwd) diff --git a/cli/azd/pkg/ext/models.go b/cli/azd/pkg/ext/models.go index d5f5de34ab4..3c6dcb7e6ae 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -38,7 +38,7 @@ var ( // from the script path and was not explicitly configured. ErrScriptTypeUnknown error = errors.New( "unable to determine hook kind. " + - "Ensure 'kind', 'language', or 'shell' is set in your hook configuration, " + + "Ensure 'kind' or 'shell' is set in your hook configuration, " + "or use a file with a recognized extension " + "(.sh, .ps1, .py, .js, .ts, .cs)", ) @@ -48,12 +48,11 @@ var ( "Set 'run' to an inline script or a relative file path", ) // ErrUnsupportedScriptType indicates the script file has an extension - // that is not recognized and no explicit kind, language, or shell was set. + // that is not recognized and no explicit kind or shell was set. ErrUnsupportedScriptType error = errors.New( "script type is not valid. " + "Supported extensions: .sh, .ps1, .py, .js, .ts, .cs. " + - "Alternatively, set 'kind' (e.g. kind: python), " + - "'language' (e.g. language: python), " + + "Alternatively, set 'kind' (e.g. kind: python) " + "or 'shell' (e.g. shell: sh)", ) ) @@ -89,20 +88,16 @@ type HookConfig struct { // Kind specifies the executor type of the hook script. // Allowed values: "sh", "pwsh", "js", "ts", "python", "dotnet". // When empty, the kind is auto-detected from the file extension - // of the run path (e.g. .py → python, .ps1 → pwsh). If Kind, - // Language, and Shell are all empty and run references a file, - // the extension is used. For inline scripts, Kind, Shell, or - // Language must be set explicitly. + // of the run path (e.g. .py → python, .ps1 → pwsh). If Kind + // and Shell are both empty and run references a file, + // the extension is used. For inline scripts, Kind or Shell + // must be set explicitly. Kind language.HookKind `yaml:"kind,omitempty" json:"kind,omitempty"` - // Language is a deprecated alias for Kind. When set in YAML - // (language: python) it is mapped to Kind during validate(). - // Retained for backwards compatibility. - Language string `yaml:"language,omitempty" json:"-"` // Shell is a deprecated alias for Kind. When set in YAML // (shell: sh) it is mapped to Kind during validate(). // Retained for backwards compatibility with legacy configs. Shell string `yaml:"shell,omitempty" json:"-"` - // Dir specifies the working directory for language hook execution, + // Dir specifies the working directory for 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 @@ -129,10 +124,9 @@ type HookConfig struct { // // Kind resolution priority: // 1. Explicit Kind field -// 2. Explicit Language field (deprecated alias, mapped to Kind) -// 3. Explicit Shell field (deprecated alias, mapped to Kind) -// 4. File extension via [language.InferKindFromPath] -// 5. OS default (Bash on Unix, PowerShell on Windows) for inline scripts +// 2. Explicit Shell field (deprecated alias, mapped to Kind) +// 3. File extension via [language.InferKindFromPath] +// 4. OS default (Bash on Unix, PowerShell on Windows) for inline scripts // // After a successful call, Kind is the single source of truth for // executor selection and the hook is ready for execution. @@ -199,11 +193,8 @@ func (hc *HookConfig) validate() error { } // Kind resolution — priority: - // 1. explicit Kind 2. Language alias 3. Shell alias - // 4. file extension 5. OS default for inline scripts - if hc.Kind == language.HookKindUnknown && hc.Language != "" { - hc.Kind = language.HookKind(hc.Language) - } + // 1. explicit Kind 2. Shell alias + // 3. file extension 4. OS default for inline scripts if hc.Kind == language.HookKindUnknown && hc.Shell != "" { hc.Kind = language.HookKind(hc.Shell) } diff --git a/cli/azd/pkg/ext/models_test.go b/cli/azd/pkg/ext/models_test.go index 2fd8fd482e4..de8449e61de 100644 --- a/cli/azd/pkg/ext/models_test.go +++ b/cli/azd/pkg/ext/models_test.go @@ -75,12 +75,6 @@ func TestHookConfig_KindField(t *testing.T) { expectedKind: language.HookKindPython, expectedDir: "src/hooks", }, - { - name: "LanguageAliasMapsPython", - yamlInput: "run: scripts/hook.py\nlanguage: python\n", - expectedKind: language.HookKindUnknown, // alias only resolved at validate() - expectedDir: "", - }, } for _, tt := range tests { @@ -167,17 +161,6 @@ func TestHookConfig_ValidateKindResolution(t *testing.T) { expectedKind: language.HookKindPython, isShell: false, }, - { - name: "LanguageAliasMapsToKind", - config: HookConfig{ - Name: "test", - Language: string(language.HookKindPython), - Run: "script.py", - }, - createFile: "script.py", - expectedKind: language.HookKindPython, - isShell: false, - }, { name: "ShellAliasBashMapsToKind", config: HookConfig{ diff --git a/cli/azd/pkg/ext/python_hooks_e2e_test.go b/cli/azd/pkg/ext/python_hooks_e2e_test.go index e3676336d5f..3579b7926dd 100644 --- a/cli/azd/pkg/ext/python_hooks_e2e_test.go +++ b/cli/azd/pkg/ext/python_hooks_e2e_test.go @@ -111,7 +111,7 @@ func stubPythonVersionCheck( // --------------------------------------------------------------------------- // TestPythonHook_AutoDetectFromExtension verifies that a hook with -// run: script.py (no explicit language:) auto-detects Python and +// run: script.py (no explicit kind:) auto-detects Python and // routes through the HookExecutor pipeline. func TestPythonHook_AutoDetectFromExtension(t *testing.T) { scriptRel := filepath.Join("hooks", "predeploy.py") @@ -125,7 +125,7 @@ func TestPythonHook_AutoDetectFromExtension(t *testing.T) { "predeploy": {{ Name: "predeploy", Run: scriptRel, - // Language intentionally omitted. + // Kind intentionally omitted. }}, } @@ -158,7 +158,7 @@ func TestPythonHook_AutoDetectFromExtension(t *testing.T) { "auto-detected Python hook should execute the .py script", ) - // Verify the config was resolved as a non-shell language hook. + // Verify the config was resolved as a non-shell hook. hookCfg := hooksMap["predeploy"][0] assert.Equal( t, language.HookKindPython, hookCfg.Kind, @@ -166,10 +166,10 @@ func TestPythonHook_AutoDetectFromExtension(t *testing.T) { assert.False(t, hookCfg.Kind.IsShell()) } -// TestPythonHook_ExplicitLanguage verifies that language: python +// TestPythonHook_ExplicitKind verifies that kind: python // in the config uses the Python executor even when the script has // no .py extension. -func TestPythonHook_ExplicitLanguage(t *testing.T) { +func TestPythonHook_ExplicitKind(t *testing.T) { scriptRel := filepath.Join("hooks", "myscript") cwd := newPythonTestFixture(t, scriptRel, false) @@ -211,7 +211,7 @@ func TestPythonHook_ExplicitLanguage(t *testing.T) { require.NoError(t, err) require.True( t, executed, - "explicit language: python should use Python executor", + "explicit kind: python should use Python executor", ) } @@ -685,7 +685,7 @@ func TestPythonHook_ShellHookUnaffected(t *testing.T) { require.NoError(t, err) require.True( t, pythonRan, - "Python hook should execute via language pipeline", + "Python hook should execute via non-shell pipeline", ) } @@ -713,7 +713,7 @@ func TestPythonHook_ExecutionPipeline(t *testing.T) { wantErr: false, }, { - name: "SuccessExplicitLanguage", + name: "SuccessExplicitKind", scriptRel: filepath.Join("hooks", "run"), kind: language.HookKindPython, exitCode: 0, @@ -863,7 +863,7 @@ func TestPythonHook_PythonBinaryResolution(t *testing.T) { // TestPythonHook_ExplicitDirOverridesCwd verifies that // the Dir field in HookConfig overrides the default working -// directory for language hook execution. +// directory for hook execution. func TestPythonHook_ExplicitDirOverridesCwd(t *testing.T) { cwd := t.TempDir() ostest.Chdir(t, cwd) diff --git a/cli/azd/pkg/project/project_config_test.go b/cli/azd/pkg/project/project_config_test.go index 25e2fdc0ab7..279d6067cde 100644 --- a/cli/azd/pkg/project/project_config_test.go +++ b/cli/azd/pkg/project/project_config_test.go @@ -15,6 +15,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/ext" "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/snapshot" "github.com/braydonk/yaml" @@ -476,7 +477,7 @@ func Test_Hooks_Config_Yaml_Marshalling(t *testing.T) { Hooks: HooksConfig{ "postprovision": { { - Shell: "sh", + Shell: string(language.HookKindBash), Run: "scripts/postprovision.sh", }, }, @@ -489,7 +490,7 @@ func Test_Hooks_Config_Yaml_Marshalling(t *testing.T) { Hooks: HooksConfig{ "postprovision": { { - Shell: "sh", + Shell: string(language.HookKindBash), Run: "scripts/postprovision.sh", }, }, @@ -514,11 +515,11 @@ func Test_Hooks_Config_Yaml_Marshalling(t *testing.T) { Hooks: map[string][]*ext.HookConfig{ "postprovision": { { - Shell: "sh", + Shell: string(language.HookKindBash), Run: "scripts/postprovision1.sh", }, { - Shell: "sh", + Shell: string(language.HookKindBash), Run: "scripts/postprovision2.sh", }, }, @@ -531,11 +532,11 @@ func Test_Hooks_Config_Yaml_Marshalling(t *testing.T) { Hooks: HooksConfig{ "postprovision": { { - Shell: "sh", + Shell: string(language.HookKindBash), Run: "scripts/postprovision1.sh", }, { - Shell: "sh", + Shell: string(language.HookKindBash), Run: "scripts/postprovision2.sh", }, }, diff --git a/cli/azd/pkg/project/project_test.go b/cli/azd/pkg/project/project_test.go index 499f6e9181d..4c28b97bd9b 100644 --- a/cli/azd/pkg/project/project_test.go +++ b/cli/azd/pkg/project/project_test.go @@ -18,6 +18,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/infra" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "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/mockarmresources" "github.com/azure/azure-dev/cli/azd/test/mocks/mockazapi" @@ -389,7 +390,7 @@ postbuild: expectedHooks := HooksConfig{ "prebuild": {{ Name: "", - Shell: "sh", + Shell: string(language.HookKindBash), Run: "./pre-build.sh", ContinueOnError: false, Interactive: false, @@ -398,7 +399,7 @@ postbuild: }}, "postbuild": {{ Name: "", - Shell: "pwsh", + Shell: string(language.HookKindPowerShell), Run: "./post-build.ps1", ContinueOnError: false, Interactive: false, @@ -418,7 +419,7 @@ postbuild: Services: map[string]*ServiceConfig{}, Hooks: HooksConfig{ "prebuild": {{ - Shell: "sh", + Shell: string(language.HookKindBash), Run: "./pre-build.sh", }}, }, @@ -454,7 +455,7 @@ postbuild: expectedHooks := HooksConfig{ "prebuild": {{ Name: "", - Shell: "sh", + Shell: string(language.HookKindBash), Run: "./pre-build.sh", ContinueOnError: false, Interactive: false, @@ -462,7 +463,7 @@ postbuild: Posix: nil, }, { Name: "", - Shell: "sh", + Shell: string(language.HookKindBash), Run: "./pre-build-external.sh", ContinueOnError: false, Interactive: false, @@ -471,7 +472,7 @@ postbuild: }}, "postbuild": {{ Name: "", - Shell: "pwsh", + Shell: string(language.HookKindPowerShell), Run: "./post-build.ps1", ContinueOnError: false, Interactive: false, From 097ed07e5e614e8d7085d2897d8858281a9d6a20 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 8 Apr 2026 10:48:50 -0700 Subject: [PATCH 24/25] fix: address PR review feedback (schema, path traversal, cleanup ordering, temp file dedup) - Rename schema property 'language' to 'kind' to match HookConfig struct - Add containment check on resolvedScriptPath to prevent path traversal - Move defer Cleanup() before Prepare() for defensive cleanup - Extract CreateInlineTempScript() shared helper from bash/powershell Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/middleware/hooks_test.go | 3 + cli/azd/pkg/ext/hooks_runner.go | 10 +-- cli/azd/pkg/ext/models.go | 83 ++++++++++++++++++++++ cli/azd/pkg/tools/bash/bash.go | 35 ++------- cli/azd/pkg/tools/powershell/powershell.go | 34 ++------- cli/azd/pkg/tools/script.go | 43 +++++++++++ schemas/alpha/azure.yaml.json | 14 ++-- schemas/v1.0/azure.yaml.json | 14 ++-- 8 files changed, 161 insertions(+), 75 deletions(-) diff --git a/cli/azd/cmd/middleware/hooks_test.go b/cli/azd/cmd/middleware/hooks_test.go index f38ae5c5dd7..d155c3426da 100644 --- a/cli/azd/cmd/middleware/hooks_test.go +++ b/cli/azd/cmd/middleware/hooks_test.go @@ -739,6 +739,7 @@ func Test_CommandHooks_ChildAction_HooksStillFire(t *testing.T) { func Test_CommandHooks_ServiceHooks_DoNotDuplicateAcrossParentAndChildRuns(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) projectConfig := createServiceHookProjectConfig(t, "predeploy") runOptions := Options{CommandPath: "azd deploy"} @@ -776,6 +777,7 @@ func Test_CommandHooks_ServiceHooks_DoNotDuplicateAcrossParentAndChildRuns(t *te func Test_CommandHooks_ServiceHooks_DoNotDuplicateAcrossRetries(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) projectConfig := createServiceHookProjectConfig(t, "predeploy") runOptions := Options{CommandPath: "azd deploy"} @@ -808,6 +810,7 @@ func Test_CommandHooks_ServiceHooks_DoNotDuplicateAcrossRetries(t *testing.T) { func Test_CommandHooks_ServiceHooks_RegisterForChildOnlyWorkflowRuns(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) projectConfig := createServiceHookProjectConfig(t, "predeploy") runOptions := Options{CommandPath: "azd deploy"} diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index a84b6117af9..cbc5f72083a 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -208,6 +208,12 @@ func (h *HooksRunner) execHook( } } + // Cleanup temp resources created during Prepare (e.g. inline + // script temp files). Deferred before Prepare so cleanup runs + // even if Prepare fails partway through. Cleanup is safe to + // call when Prepare was not called or created no resources. + defer executor.Cleanup(ctx) + // Prepare (unified — venv/deps for Python, pwsh detection for // PowerShell, inline temp file creation for Bash/PowerShell hooks). log.Printf( @@ -219,10 +225,6 @@ func (h *HooksRunner) execHook( return fmt.Errorf("preparing hook '%s': %w", hookConfig.Name, err) } - // Cleanup temp resources created during Prepare (e.g. inline - // script temp files). Runs after Execute regardless of outcome. - defer executor.Cleanup(ctx) - // Configure console/previewer. if h.configureExecContext(ctx, hookConfig, &execCtx) { defer h.console.StopPreviewer(ctx, false) diff --git a/cli/azd/pkg/ext/models.go b/cli/azd/pkg/ext/models.go index 3c6dcb7e6ae..1747fdfbdb2 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -4,11 +4,16 @@ package ext import ( + "crypto/sha256" + "encoding/hex" "errors" "fmt" + "maps" "os" "path/filepath" "runtime" + "slices" + "strconv" "strings" "github.com/azure/azure-dev/cli/azd/pkg/osutil" @@ -316,6 +321,30 @@ func (hc *HookConfig) validate() error { } hc.resolvedDir = absDir + // Validate that resolvedScriptPath stays within the project + // root to prevent path traversal via ".." in Run. + if hc.resolvedScriptPath != "" { + absScript, scriptErr := filepath.Abs( + hc.resolvedScriptPath, + ) + if scriptErr != nil { + return fmt.Errorf( + "resolving hook script path: %w", + scriptErr, + ) + } + if !osutil.IsPathContained( + absBoundary, absScript, + ) { + return fmt.Errorf( + "hook script path %q escapes "+ + "project root %q", + hc.Run, boundaryDir, + ) + } + hc.resolvedScriptPath = absScript + } + hc.validated = true return nil @@ -375,6 +404,60 @@ func InferHookType(name string) (HookType, string) { return HookTypeNone, name } +// HooksConfigSignature returns a stable signature for a set of hook configurations. +// The signature ignores runtime-only state and changes whenever the effective hook config changes. +func HooksConfigSignature(hooks map[string][]*HookConfig) string { + if len(hooks) == 0 { + return "" + } + + var builder strings.Builder + + for _, hookName := range slices.Sorted(maps.Keys(hooks)) { + builder.WriteString(hookName) + builder.WriteByte('\x00') + + for index, hookConfig := range hooks[hookName] { + builder.WriteString(strconv.Itoa(index)) + builder.WriteByte('\x00') + appendHookConfigSignature(&builder, hookConfig) + } + } + + sum := sha256.Sum256([]byte(builder.String())) + return hex.EncodeToString(sum[:]) +} + +func appendHookConfigSignature(builder *strings.Builder, hookConfig *HookConfig) { + if hookConfig == nil { + builder.WriteString("\x00") + return + } + + builder.WriteString(string(hookConfig.Kind)) + builder.WriteByte('\x00') + builder.WriteString(hookConfig.Shell) + builder.WriteByte('\x00') + builder.WriteString(hookConfig.Run) + builder.WriteByte('\x00') + builder.WriteString(hookConfig.Dir) + builder.WriteByte('\x00') + builder.WriteString(strconv.FormatBool(hookConfig.ContinueOnError)) + builder.WriteByte('\x00') + builder.WriteString(strconv.FormatBool(hookConfig.Interactive)) + builder.WriteByte('\x00') + + for _, secretName := range slices.Sorted(maps.Keys(hookConfig.Secrets)) { + builder.WriteString(secretName) + builder.WriteByte('=') + builder.WriteString(hookConfig.Secrets[secretName]) + builder.WriteByte('\x00') + } + + appendHookConfigSignature(builder, hookConfig.Windows) + appendHookConfigSignature(builder, hookConfig.Posix) +} + // defaultKindForOS returns the default shell kind for the // current operating system: PowerShell on Windows, Bash elsewhere. func defaultKindForOS() language.HookKind { diff --git a/cli/azd/pkg/tools/bash/bash.go b/cli/azd/pkg/tools/bash/bash.go index ef813f0ebad..2c4e28a5879 100644 --- a/cli/azd/pkg/tools/bash/bash.go +++ b/cli/azd/pkg/tools/bash/bash.go @@ -5,13 +5,11 @@ package bash import ( "context" - "fmt" "os" "runtime" "strings" "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" ) @@ -34,38 +32,17 @@ func (b *bashExecutor) Prepare( return nil } - tmpFile, err := os.CreateTemp( - "", fmt.Sprintf("azd-%s-*.sh", execCtx.HookName), - ) - if err != nil { - return fmt.Errorf("creating temp script: %w", err) - } - content := "#!/bin/sh\nset -e\n\n" + "# Auto generated file from Azure Developer CLI\n" + execCtx.InlineScript + "\n" - if err := os.WriteFile( - tmpFile.Name(), []byte(content), osutil.PermissionExecutableFile, - ); err != nil { - tmpFile.Close() - os.Remove(tmpFile.Name()) - return fmt.Errorf("writing temp script: %w", err) - } - - // os.WriteFile only applies perm when *creating* a new file. - // The file already exists from CreateTemp (mode 0600), so we - // must explicitly chmod to add execute permission for Unix. - if err := os.Chmod( - tmpFile.Name(), osutil.PermissionExecutableFile, - ); err != nil { - tmpFile.Close() - os.Remove(tmpFile.Name()) - return fmt.Errorf("setting temp script permissions: %w", err) + path, err := tools.CreateInlineTempScript( + execCtx.HookName, ".sh", content, + ) + if err != nil { + return err } - - tmpFile.Close() - b.tempFile = tmpFile.Name() + b.tempFile = path return nil } diff --git a/cli/azd/pkg/tools/powershell/powershell.go b/cli/azd/pkg/tools/powershell/powershell.go index eb58fc989cb..050607f1b38 100644 --- a/cli/azd/pkg/tools/powershell/powershell.go +++ b/cli/azd/pkg/tools/powershell/powershell.go @@ -11,7 +11,6 @@ import ( "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/osutil" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/tools" ) @@ -45,40 +44,19 @@ func (p *powershellExecutor) Prepare( // Create temp file for inline scripts. if execCtx.InlineScript != "" { - tmpFile, err := os.CreateTemp( - "", fmt.Sprintf("azd-%s-*.ps1", execCtx.HookName), - ) - if err != nil { - return fmt.Errorf("creating temp script: %w", err) - } - content := "$ErrorActionPreference = 'Stop'\n\n" + "# Auto generated file from Azure Developer CLI\n" + execCtx.InlineScript + "\n" + "if ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) " + "{ exit $LASTEXITCODE }\n" - if err := os.WriteFile( - tmpFile.Name(), []byte(content), osutil.PermissionExecutableFile, - ); err != nil { - tmpFile.Close() - os.Remove(tmpFile.Name()) - return fmt.Errorf("writing temp script: %w", err) - } - - // os.WriteFile only applies perm when *creating* a new file. - // The file already exists from CreateTemp (mode 0600), so we - // must explicitly chmod to add execute permission for Unix. - if err := os.Chmod( - tmpFile.Name(), osutil.PermissionExecutableFile, - ); err != nil { - tmpFile.Close() - os.Remove(tmpFile.Name()) - return fmt.Errorf("setting temp script permissions: %w", err) + path, err := tools.CreateInlineTempScript( + execCtx.HookName, ".ps1", content, + ) + if err != nil { + return err } - - tmpFile.Close() - p.tempFile = tmpFile.Name() + p.tempFile = path } return nil diff --git a/cli/azd/pkg/tools/script.go b/cli/azd/pkg/tools/script.go index d2324b2e54f..0740aa516c4 100644 --- a/cli/azd/pkg/tools/script.go +++ b/cli/azd/pkg/tools/script.go @@ -5,9 +5,12 @@ package tools import ( "context" + "fmt" "io" + "os" "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" ) // ExecutionContext provides the per-invocation execution environment @@ -66,3 +69,43 @@ type HookExecutor interface { // even when Prepare was not called or created no resources. Cleanup(ctx context.Context) error } + +// CreateInlineTempScript creates a temp file for an inline hook +// script with executable permissions. The caller is responsible +// for cleaning up the returned file. +func CreateInlineTempScript( + hookName, ext, content string, +) (string, error) { + file, err := os.CreateTemp( + "", fmt.Sprintf("azd-%s-*%s", hookName, ext), + ) + if err != nil { + return "", fmt.Errorf( + "failed creating temp file: %w", err, + ) + } + filePath := file.Name() + file.Close() + + if err := os.WriteFile( + filePath, []byte(content), + osutil.PermissionExecutableFile, + ); err != nil { + os.Remove(filePath) + return "", fmt.Errorf( + "failed writing temp script: %w", err, + ) + } + + if err := os.Chmod( + filePath, osutil.PermissionExecutableFile, + ); err != nil { + os.Remove(filePath) + return "", fmt.Errorf( + "failed setting executable permission: %w", + err, + ) + } + + return filePath, nil +} diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index a1eaf79c581..8b60cc53ae1 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -827,10 +827,10 @@ ], "default": "sh" }, - "language": { + "kind": { "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.", + "title": "Executor kind for the hook script", + "description": "Optional. Specifies the executor kind used to run the hook script. When omitted, the kind is auto-detected from the file extension of the 'run' path (e.g. .py → python, .ps1 → pwsh). Shell kinds (sh, pwsh) use the existing shell runner; other kinds use a language-specific executor that handles dependency installation and runtime management.", "enum": [ "sh", "pwsh", @@ -842,8 +842,8 @@ }, "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." + "title": "Working directory for hook execution", + "description": "Optional. Specifies the working directory for 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", @@ -908,7 +908,7 @@ "properties": { "run": false, "shell": false, - "language": false, + "kind": false, "dir": false, "interactive": false, "continueOnError": false, @@ -941,7 +941,7 @@ }, { "required": [ - "language" + "kind" ] }, { diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index c61cb910e26..90ffed2f2b1 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -787,10 +787,10 @@ ], "default": "sh" }, - "language": { + "kind": { "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.", + "title": "Executor kind for the hook script", + "description": "Optional. Specifies the executor kind used to run the hook script. When omitted, the kind is auto-detected from the file extension of the 'run' path (e.g. .py → python, .ps1 → pwsh). Shell kinds (sh, pwsh) use the existing shell runner; other kinds use a language-specific executor that handles dependency installation and runtime management.", "enum": [ "sh", "pwsh", @@ -802,8 +802,8 @@ }, "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." + "title": "Working directory for hook execution", + "description": "Optional. Specifies the working directory for 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", @@ -868,7 +868,7 @@ "properties": { "run": false, "shell": false, - "language": false, + "kind": false, "dir": false, "interactive": false, "continueOnError": false, @@ -901,7 +901,7 @@ }, { "required": [ - "language" + "kind" ] }, { From f6247cbae9b451afafb143f58bb61a545e27e39a Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 9 Apr 2026 10:34:10 -0700 Subject: [PATCH 25/25] fix: log cleanup errors, add cspell override for copylocks - Wrap defer Cleanup() in closure to log errors instead of silently dropping - Add copylocks to cspell overrides for project.go Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/.vscode/cspell.yaml | 3 +++ cli/azd/pkg/ext/hooks_runner.go | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index e40948d49d5..32e0aaf77d3 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -371,6 +371,9 @@ overrides: - filename: pkg/infra/provisioning/bicep/local_preflight.go words: - actioned + - filename: pkg/project/project.go + words: + - copylocks - filename: pkg/ext/models.go words: - unvalidated diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index cbc5f72083a..3e91495a9b6 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -212,7 +212,11 @@ func (h *HooksRunner) execHook( // script temp files). Deferred before Prepare so cleanup runs // even if Prepare fails partway through. Cleanup is safe to // call when Prepare was not called or created no resources. - defer executor.Cleanup(ctx) + defer func() { + if cErr := executor.Cleanup(ctx); cErr != nil { + log.Printf("warning: cleanup failed for hook '%s': %v\n", hookConfig.Name, cErr) + } + }() // Prepare (unified — venv/deps for Python, pwsh detection for // PowerShell, inline temp file creation for Bash/PowerShell hooks).