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/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 6c32f308483..32e0aaf77d3 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -262,6 +262,11 @@ overrides: - 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 @@ -307,6 +312,12 @@ overrides: - 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 @@ -342,18 +353,36 @@ overrides: 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/project/project.go words: - copylocks + - filename: pkg/ext/models.go + words: + - unvalidated + - venvs + - workdir + - filename: docs/code-coverage-guide.md + words: + - covdata + - GOWORK ignorePaths: - "**/*_test.go" - "**/mock*.go" diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 4593a80218c..1b040c56e7d 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.HookKind]any{ + language.HookKindBash: bash.NewExecutor, + language.HookKindPowerShell: powershell.NewExecutor, + language.HookKindPython: language.NewPythonExecutor, + } + + for kind, constructor := range hookExecutorMap { + container.MustRegisterNamedTransient(string(kind), constructor) + } + // Pipelines container.MustRegisterScoped(pipeline.NewPipelineManager) container.MustRegisterSingleton(func(flags *pipelineConfigFlags) *pipeline.PipelineManagerArgs { diff --git a/cli/azd/cmd/hooks.go b/cli/azd/cmd/hooks.go index 6f4cce15ae4..925f9db327c 100644 --- a/cli/azd/cmd/hooks.go +++ b/cli/azd/cmd/hooks.go @@ -337,7 +337,7 @@ func (hra *hooksRunAction) execHook( hooksManager, hra.commandRunner, hra.envManager, hra.console, cwd, hooksMap, hra.env, hra.serviceLocator) // Always run in interactive mode for 'azd hooks run', to help with testing/debugging - runOptions := &tools.ExecOptions{ + runOptions := &tools.ExecutionContext{ Interactive: new(true), } diff --git a/cli/azd/cmd/hooks_test.go b/cli/azd/cmd/hooks_test.go index 5307c5cd80e..f0bc5c4d7ad 100644 --- a/cli/azd/cmd/hooks_test.go +++ b/cli/azd/cmd/hooks_test.go @@ -16,14 +16,22 @@ 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/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" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) +// registerHookExecutors delegates to the shared test helper in test/mocks/mocktools. +func registerHookExecutors(mockCtx *mocks.MockContext) { + mocktools.RegisterHookExecutors(mockCtx) +} + 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) @@ -42,7 +50,7 @@ func Test_HooksRunAction_RunsLayerHooks(t *testing.T) { Path: "infra/core", Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), Run: "echo core", }}, }, @@ -52,7 +60,7 @@ func Test_HooksRunAction_RunsLayerHooks(t *testing.T) { Path: absoluteLayerPath, Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), Run: "echo shared", }}, }, @@ -92,6 +100,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) @@ -109,7 +118,7 @@ func Test_HooksRunAction_FiltersLayerHooks(t *testing.T) { Path: "infra/core", Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), Run: "echo core", }}, }, @@ -119,7 +128,7 @@ func Test_HooksRunAction_FiltersLayerHooks(t *testing.T) { Path: "infra/shared", Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), Run: "echo shared", }}, }, @@ -158,6 +167,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) @@ -178,7 +188,7 @@ func Test_HooksRunAction_SetsTelemetryTypeForLayer(t *testing.T) { Path: "infra/core", Hooks: provisioning.HooksConfig{ "preprovision": {{ - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), Run: "echo core", }}, }, @@ -272,5 +282,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 language from the .sh file extension + 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 12b50fe8732..d155c3426da 100644 --- a/cli/azd/cmd/middleware/hooks_test.go +++ b/cli/azd/cmd/middleware/hooks_test.go @@ -20,8 +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/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" "github.com/azure/azure-dev/cli/azd/test/ostest" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -29,6 +31,7 @@ import ( func Test_CommandHooks_Middleware_WithValidProjectAndMatchingCommand(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -40,7 +43,7 @@ func Test_CommandHooks_Middleware_WithValidProjectAndMatchingCommand(t *testing. "precommand": { { Run: "echo 'hello'", - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), }, }, }, @@ -63,6 +66,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" @@ -74,7 +78,7 @@ func Test_CommandHooks_Middleware_ValidProjectWithDifferentCommand(t *testing.T) "precommand": { { Run: "echo 'hello'", - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), }, }, }, @@ -97,6 +101,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 +128,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" @@ -134,7 +140,7 @@ func Test_CommandHooks_Middleware_PreHookWithError(t *testing.T) { "precommand": { { Run: "exit 1", - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), }, }, }, @@ -160,6 +166,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" @@ -171,7 +178,7 @@ func Test_CommandHooks_Middleware_PreHookWithErrorAndContinue(t *testing.T) { "precommand": { { Run: "exit 1", - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), ContinueOnError: true, }, }, @@ -198,6 +205,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" @@ -209,7 +217,7 @@ func Test_CommandHooks_Middleware_WithCmdAlias(t *testing.T) { "prealias": { { Run: "echo 'hello'", - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), }, }, }, @@ -232,6 +240,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" @@ -250,7 +259,7 @@ func Test_ServiceHooks_Registered(t *testing.T) { Hooks: map[string][]*ext.HookConfig{ "predeploy": { { - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), Run: "echo 'Hello'", }, }, @@ -294,6 +303,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" @@ -514,6 +524,7 @@ services: func Test_PowerShellWarning_WithPowerShellHooks(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) azdContext := createAzdContext(t) envName := "test" @@ -525,7 +536,7 @@ func Test_PowerShellWarning_WithPowerShellHooks(t *testing.T) { "preprovision": { { Run: "Write-Host 'hello'", - Shell: ext.ShellTypePowershell, + Shell: string(language.HookKindPowerShell), }, }, }, @@ -562,6 +573,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" @@ -572,8 +584,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: string(language.HookKindPowerShell), // Explicitly specify shell to avoid detection issues }, }, }, @@ -608,6 +620,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" @@ -619,7 +632,7 @@ func Test_PowerShellWarning_WithoutPowerShellHooks(t *testing.T) { "precommand": { { Run: "echo 'hello'", - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), }, }, }, @@ -686,6 +699,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" @@ -697,7 +711,7 @@ func Test_CommandHooks_ChildAction_HooksStillFire(t *testing.T) { tt.hookName: { { Run: "echo 'hook running'", - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), }, }, }, @@ -725,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"} @@ -762,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"} @@ -794,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"} @@ -846,6 +863,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" @@ -857,13 +875,13 @@ func Test_CommandHooks_ChildAction_SkipsValidationOnly(t *testing.T) { "preprovision": { { Run: "echo 'preprovision hook'", - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), }, }, "postprovision": { { Run: "echo 'postprovision hook'", - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), }, }, }, @@ -902,6 +920,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" @@ -913,7 +932,7 @@ func Test_CommandHooks_ChildAction_PreHookError_StopsAction(t *testing.T) { "preprovision": { { Run: "exit 1", - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), }, }, }, @@ -939,6 +958,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" @@ -950,7 +970,7 @@ func Test_PowerShellWarning_WithPwshAvailable(t *testing.T) { "precommand": { { Run: "Write-Host 'hello'", - Shell: ext.ShellTypePowershell, + Shell: string(language.HookKindPowerShell), }, }, }, @@ -985,6 +1005,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" @@ -996,7 +1017,7 @@ func Test_PowerShellWarning_WithNoPowerShellInstalled(t *testing.T) { "preprovision": { { Run: "Write-Host 'hello'", - Shell: ext.ShellTypePowershell, + Shell: string(language.HookKindPowerShell), }, }, }, @@ -1030,3 +1051,8 @@ 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 new file mode 100644 index 00000000000..094c748273c --- /dev/null +++ b/cli/azd/docs/language-hooks.md @@ -0,0 +1,170 @@ +# Hooks + +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 Executor Types + +| 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: + +### `kind` (string, optional) + +Specifies the executor type for the hook. Allowed values: +`sh`, `pwsh`, `js`, `ts`, `python`, `dotnet`. + +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) — working directory + +The working directory (`cwd`) for hook execution. Used as the project context +for dependency installation (e.g. `pip install` from `requirements.txt`) and +builds. + +**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. + +## Examples + +### Python hook — auto-detected from .py extension + +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. + +```yaml +hooks: + postprovision: + run: ./hooks/seed-database.py +``` + +### Python hook in a subdirectory (dir auto-inferred) + +When the script lives in a subdirectory, the `dir` is automatically set to that +directory. No explicit `dir` field is needed: + +```yaml +hooks: + preprovision: + run: hooks/preprovision/main.py + # dir is auto-inferred as hooks/preprovision/ +``` + +### Python hook — explicit kind + +When auto-detection is not desired or the file extension is ambiguous, set +the `kind` field explicitly to select the Python executor: + +```yaml +hooks: + postprovision: + run: ./hooks/setup.py + kind: python +``` + +### Python hook with working directory override + +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: ./tools/scripts/seed.py + dir: ./tools # override: requirements.txt is in ./tools, not ./tools/scripts +``` + +### 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 + kind: python +``` + +### Python hook with secrets + +Hooks support the `secrets` field for resolving Azure Key Vault references, +regardless of executor type: + +```yaml +hooks: + postprovision: + run: ./hooks/seed-database.py + secrets: + DB_CONNECTION_STRING: DATABASE_URL +``` + +### Bash hook (existing behavior, unchanged) + +Bash hooks continue to work exactly as before. The `kind` field is +optional and defaults to the appropriate shell type: + +```yaml +hooks: + preprovision: + run: echo "Provisioning starting..." + shell: sh +``` + +## How It Works + +Every hook follows the unified **Prepare → Execute → Cleanup** lifecycle: + +1. **Prepare** — The executor validates prerequisites and performs any + setup. This includes: + - **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`). + - **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 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..ee7eb8556df 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: ShellTypeBash, + Shell: string(language.HookKindBash), Run: "scripts/preprovision.sh", }, }, @@ -49,11 +50,11 @@ preprovision: require.Equal(t, HooksConfig{ "preprovision": { { - Shell: ShellTypeBash, + Shell: string(language.HookKindBash), Run: "scripts/preprovision-1.sh", }, { - Shell: ShellTypeBash, + Shell: string(language.HookKindBash), Run: "scripts/preprovision-2.sh", }, }, @@ -66,7 +67,7 @@ func TestHooksConfig_MarshalYAML(t *testing.T) { hooks := HooksConfig{ "preprovision": { { - Shell: ShellTypeBash, + Shell: string(language.HookKindBash), Run: "scripts/preprovision.sh", }, }, @@ -86,11 +87,11 @@ preprovision: hooks := HooksConfig{ "preprovision": { { - Shell: ShellTypeBash, + Shell: string(language.HookKindBash), Run: "scripts/preprovision-1.sh", }, { - Shell: ShellTypeBash, + 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 4d5e9a06558..5c41b1f3d6a 100644 --- a/cli/azd/pkg/ext/hooks_manager.go +++ b/cli/azd/pkg/ext/hooks_manager.go @@ -14,14 +14,17 @@ 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 -// 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 @@ -134,6 +137,7 @@ type HookValidationResult struct { type HookWarning struct { Message string Suggestion string + URL string } // ValidateHooks validates hook configurations and returns any warnings @@ -166,19 +170,18 @@ 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) 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 kind/shell and it's an inline script, set + // OS default Kind for warning purposes. + if hookConfig.Shell == "" && + hookConfig.Kind == language.HookKindUnknown && + isInlineScript { + hookConfig.Kind = defaultKindForOS() hookConfig.usingDefaultShell = true } } @@ -246,5 +249,135 @@ func (h *HooksManager) ValidateHooks(ctx context.Context, allHooks map[string][] log.Println(warningMessage) } + // Check runtime availability for hooks that require external runtimes. + langWarnings := h.validateRuntimes(ctx, allHooks) + result.Warnings = append(result.Warnings, langWarnings...) + return result } + +// 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) validateRuntimes( + ctx context.Context, + allHooks map[string][]*HookConfig, +) []HookWarning { + var warnings []HookWarning + + // 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 { + for _, hookConfig := range hookConfigs { + if hookConfig == nil { + continue + } + + // Apply OS-specific override if present. + cfg := hookConfig + if runtime.GOOS == "windows" && cfg.Windows != nil { + cfg = cfg.Windows + } else if (runtime.GOOS == "linux" || + runtime.GOOS == "darwin") && cfg.Posix != nil { + cfg = cfg.Posix + } + + if cfg.cwd == "" { + cfg.cwd = h.cwd + } + + // Set the hook name so that any temp scripts + // created by validate() use the correct name + // pattern (e.g. azd-predeploy-*.sh). + if cfg.Name == "" { + cfg.Name = hookName + } + + // Run validate to resolve the Kind field from + // file extension / explicit config. + if err := cfg.validate(); err != nil { + // Validation errors are surfaced by GetAll / + // GetByParams; skip the hook here. + continue + } + + // Non-shell hooks need runtime validation + // (e.g. Python must be installed). Bash and + // PowerShell hooks are validated separately above. + if !cfg.Kind.IsShell() { + if _, seen := requiredLangs[cfg.Kind]; !seen { + requiredLangs[cfg.Kind] = hookName + } + } + } + } + + // Phase 1: validate Python runtime. + if hookName, ok := requiredLangs[language.HookKindPython]; 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", + ), + ), + URL: pythonCli.InstallUrl(), + }) + } + } + + // Phase 2: JS/TS — not yet validated. + // Phase 4: DotNet — not yet validated. + + return warnings +} + +// 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.validateRuntimes(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: w.URL, + Title: w.Message, + }) + } + + return &errorhandler.ErrorWithSuggestion{ + Err: fmt.Errorf( + "missing required runtime: %s", + first.Message, + ), + Message: first.Message, + Suggestion: first.Suggestion, + Links: links, + } +} diff --git a/cli/azd/pkg/ext/hooks_manager_test.go b/cli/azd/pkg/ext/hooks_manager_test.go index 5be4b0f649b..0004ec8b859 100644 --- a/cli/azd/pkg/ext/hooks_manager_test.go +++ b/cli/azd/pkg/ext/hooks_manager_test.go @@ -5,12 +5,15 @@ 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/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" @@ -49,13 +52,13 @@ func Test_GetAllHookConfigs(t *testing.T) { hooksMap := map[string][]*HookConfig{ "preinit": { { - Shell: ShellTypeBash, + Shell: string(language.HookKindBash), // Run is missing - this should cause an error }, }, "postinit": { { - Shell: ShellTypeBash, + Shell: string(language.HookKindBash), // Run is missing - this should cause an error }, }, @@ -121,13 +124,13 @@ func Test_GetByParams(t *testing.T) { hooksMap := map[string][]*HookConfig{ "preinit": { { - Shell: ShellTypeBash, + Shell: string(language.HookKindBash), // Run is missing - this should cause an error }, }, "postinit": { { - Shell: ShellTypeBash, + Shell: string(language.HookKindBash), // Run is missing - this should cause an error }, }, @@ -148,7 +151,7 @@ func Test_HookConfig_DefaultShell(t *testing.T) { tests := []struct { name string hookConfig *HookConfig - expectedShell ShellType + expectedKind language.HookKind expectingDefault bool }{ { @@ -157,17 +160,17 @@ func Test_HookConfig_DefaultShell(t *testing.T) { Name: "test", Run: "echo 'hello'", }, - expectedShell: getDefaultShellForOS(), + expectedKind: defaultKindForOS(), expectingDefault: true, }, { name: "Shell explicitly specified - should not use default", hookConfig: &HookConfig{ Name: "test", - Shell: ShellTypeBash, + Shell: string(language.HookKindBash), Run: "echo 'hello'", }, - expectedShell: ShellTypeBash, + expectedKind: language.HookKindBash, expectingDefault: false, }, } @@ -181,12 +184,211 @@ 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.expectedKind, config.Kind) require.Equal(t, tt.expectingDefault, config.IsUsingDefaultShell()) }) } } +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 non-shell 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 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.ValidateRuntimesErr(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.ValidateRuntimesErr(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 Bash scripts only — no non-shell 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 runtime warnings for Bash/PowerShell hooks. + for _, w := range result.Warnings { + require.NotContains(t, w.Message, "Python", + "shell-only hooks must not trigger runtime warnings") + } + + require.NoError(t, + mgr.ValidateRuntimesErr(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 runtime 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.ValidateRuntimesErr(t.Context(), hooksMap) + require.Error(t, err) + require.Contains(t, err.Error(), "Python") +} + func ensureScriptsExist(t *testing.T, configs map[string][]*HookConfig) { for _, hooks := range configs { for _, hook := range hooks { diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index cd8f0fcde7b..3e91495a9b6 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -11,18 +11,17 @@ import ( "strings" "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/ioc" "github.com/azure/azure-dev/cli/azd/pkg/keyvault" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/tools" - "github.com/azure/azure-dev/cli/azd/pkg/tools/bash" - "github.com/azure/azure-dev/cli/azd/pkg/tools/powershell" ) -// Hooks enable support to invoke integration scripts before & after commands -// 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 @@ -91,7 +90,7 @@ func (h *HooksRunner) Invoke(ctx context.Context, commands []string, actionFn In func (h *HooksRunner) RunHooks( ctx context.Context, hookType HookType, - options *tools.ExecOptions, + options *tools.ExecutionContext, commands ...string, ) error { hooks, err := h.hooksManager.GetByParams(h.hooks, hookType, commands...) @@ -117,29 +116,11 @@ func (h *HooksRunner) RunHooks( return nil } -// Gets the script to execute based on the hook configuration values -// For inline scripts this will also create a temporary script file to execute -func (h *HooksRunner) GetScript(hookConfig *HookConfig, envVars []string) (tools.Script, error) { - if err := hookConfig.validate(); err != nil { - return nil, err - } - - switch ShellType(strings.Split(string(hookConfig.Shell), " ")[0]) { - case ShellTypeBash: - return bash.NewBashScript(h.commandRunner, h.cwd, envVars), nil - case ShellTypePowershell: - return powershell.NewPowershellScript(h.commandRunner, h.cwd, envVars), nil - default: - return nil, fmt.Errorf( - "shell type '%s' is not a valid option. Only 'sh' and 'pwsh' are supported", - hookConfig.Shell, - ) - } -} - -func (h *HooksRunner) execHook(ctx context.Context, hookConfig *HookConfig, options *tools.ExecOptions) error { +func (h *HooksRunner) execHook( + ctx context.Context, hookConfig *HookConfig, options *tools.ExecutionContext, +) error { if options == nil { - options = &tools.ExecOptions{} + options = &tools.ExecutionContext{} } hookEnv := environment.NewWithValues("temp", h.env.Dotenv()) @@ -166,66 +147,183 @@ 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 kind, path, shell type, + // and computes resolvedDir / resolvedScriptPath. + if err := hookConfig.validate(); err != nil { return err } - scriptPath, cleanup, err := hookConfig.PrepareExecutionPath() - if err != nil { - return err + // Use pre-resolved paths from validate(). + cwd := hookConfig.resolvedDir + if cwd == "" { + cwd = h.cwd // fallback (shouldn't happen after validate) } - formatter := h.console.GetFormatter() - consoleInteractive := (formatter == nil || formatter.Kind() == output.NoneFormat) - scriptInteractive := consoleInteractive && hookConfig.Interactive + boundaryDir := hookConfig.cwd + if boundaryDir == "" { + boundaryDir = h.cwd + } + + scriptPath := hookConfig.resolvedScriptPath + + envVars := hookEnv.Environ() - if options.Interactive == nil { - options.Interactive = &scriptInteractive + // Build execution context. + execCtx := tools.ExecutionContext{ + Cwd: cwd, + EnvVars: envVars, + BoundaryDir: boundaryDir, + InlineScript: hookConfig.script, + HookName: hookConfig.Name, } - // When the hook is not configured to run in interactive mode and no stdout has been configured - // Then show the hook execution output within the console previewer pane - if !*options.Interactive && options.StdOut == nil { - previewer := h.console.ShowPreviewer(ctx, &input.ShowPreviewerOptions{ - Prefix: " ", - Title: fmt.Sprintf("%s Hook Output", hookConfig.Name), - MaxLineCount: 8, - }) - options.StdOut = previewer + // Merge caller-provided overrides (e.g. forced interactive from 'azd hooks run'). + if options.Interactive != nil { + execCtx.Interactive = options.Interactive + } + if options.StdOut != nil { + execCtx.StdOut = options.StdOut + } + + // Resolve executor via IoC — hooks runner has NO knowledge of executor internals. + var executor tools.HookExecutor + if err := h.serviceLocator.ResolveNamed(string(hookConfig.Kind), &executor); err != nil { + return &errorhandler.ErrorWithSuggestion{ + Err: fmt.Errorf( + "no executor for kind '%s': %w", + hookConfig.Kind, err, + ), + Message: fmt.Sprintf( + "The '%s' kind is not supported for hook '%s'.", + hookConfig.Kind, + hookConfig.Name, + ), + Suggestion: "Supported hook kinds: sh, pwsh, python.", + Links: []errorhandler.ErrorLink{ + { + Title: "Hook documentation", + URL: "https://learn.microsoft.com/azure/developer/azure-developer-cli/azd-extensibility", + }, + }, + } + } + + // 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 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). + log.Printf( + "Preparing hook '%s' (%s)\n", + hookConfig.Name, hookConfig.Kind, + ) + + if err := executor.Prepare(ctx, scriptPath, execCtx); err != nil { + return fmt.Errorf("preparing hook '%s': %w", hookConfig.Name, err) + } + + // Configure console/previewer. + if h.configureExecContext(ctx, hookConfig, &execCtx) { defer h.console.StopPreviewer(ctx, false) } - options.UserPwsh = string(hookConfig.Shell) - log.Printf("Executing script '%s'\n", scriptPath) - res, err := script.Execute(ctx, scriptPath, *options) + // Execute (unified). + log.Printf( + "Executing hook '%s' (%s)\n", + hookConfig.Name, scriptPath, + ) + + res, err := executor.Execute(ctx, scriptPath, execCtx) if err != nil { - execErr := fmt.Errorf( - "'%s' hook failed with exit code: '%d', Path: '%s'. : %w", - hookConfig.Name, - res.ExitCode, - scriptPath, - err, + hookErr := h.handleHookError( + ctx, hookConfig, res, scriptPath, err, ) - - // If an error occurred log the failure but continue - if hookConfig.ContinueOnError { - h.console.Message(ctx, output.WithBold("%s", output.WithWarningFormat("WARNING: %s", execErr.Error()))) - h.console.Message( - ctx, - output.WithWarningFormat("Execution will continue since ContinueOnError has been set to true."), - ) - log.Println(execErr.Error()) - } else { - return execErr + if hookErr != nil { + return hookErr } } - // Delete any temporary inline scripts after execution. - // Removing temp scripts only on success to support better debugging with failing scripts. - if cleanup != nil { - defer cleanup() + return nil +} + +// configureExecContext resolves interactive mode and sets up the +// console previewer for non-interactive hooks that have no custom +// 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, + execCtx *tools.ExecutionContext, +) bool { + formatter := h.console.GetFormatter() + consoleInteractive := (formatter == nil || + formatter.Kind() == output.NoneFormat) + scriptInteractive := consoleInteractive && hookConfig.Interactive + + if execCtx.Interactive == nil { + execCtx.Interactive = &scriptInteractive } - return nil + // When the hook is not configured to run in interactive mode + // and no stdout has been configured, show the hook execution + // output within the console previewer pane. + if !*execCtx.Interactive && execCtx.StdOut == nil { + previewer := h.console.ShowPreviewer( + ctx, + &input.ShowPreviewerOptions{ + Prefix: " ", + Title: fmt.Sprintf("%s Hook Output", hookConfig.Name), + MaxLineCount: 8, + }, + ) + execCtx.StdOut = previewer + return true + } + + return false +} + +// handleHookError wraps a hook execution error and either returns +// it or logs a warning when ContinueOnError is set. +func (h *HooksRunner) handleHookError( + ctx context.Context, + hookConfig *HookConfig, + res exec.RunResult, + scriptPath string, + err error, +) error { + execErr := fmt.Errorf( + "'%s' hook failed with exit code: '%d', Path: '%s'. : %w", + hookConfig.Name, + res.ExitCode, + scriptPath, + err, + ) + + if hookConfig.ContinueOnError { + h.console.Message( + ctx, + output.WithBold( + "%s", + output.WithWarningFormat("WARNING: %s", execErr.Error()), + ), + ) + h.console.Message( + ctx, + output.WithWarningFormat( + "Execution will continue since ContinueOnError has been set to true.", + ), + ) + log.Println(execErr.Error()) + return nil + } + + return execErr } diff --git a/cli/azd/pkg/ext/hooks_runner_test.go b/cli/azd/pkg/ext/hooks_runner_test.go index a549774d4fa..0b85f90f923 100644 --- a/cli/azd/pkg/ext/hooks_runner_test.go +++ b/cli/azd/pkg/ext/hooks_runner_test.go @@ -5,24 +5,33 @@ package ext import ( "context" + "fmt" "os" - "reflect" + "path/filepath" "strings" "testing" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "github.com/azure/azure-dev/cli/azd/pkg/tools/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" "github.com/azure/azure-dev/cli/azd/test/ostest" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) +// registerHookExecutors delegates to the shared test helper in test/mocks/mocktools. +func registerHookExecutors(mockCtx *mocks.MockContext) { + mocktools.RegisterHookExecutors(mockCtx) +} + func Test_Hooks_Execute(t *testing.T) { cwd := t.TempDir() ostest.Chdir(t, cwd) + scriptsDir := filepath.Join(cwd, "scripts") env := environment.NewWithValues( "test", @@ -35,24 +44,24 @@ func Test_Hooks_Execute(t *testing.T) { hooksMap := map[string][]*HookConfig{ "preinline": { { - Shell: ShellTypeBash, + Shell: string(language.HookKindBash), Run: "echo 'Hello'", }, }, "precommand": { { - Shell: ShellTypeBash, + Shell: string(language.HookKindBash), Run: "scripts/precommand.sh", }, }, "postcommand": {{ - Shell: ShellTypeBash, + Shell: string(language.HookKindBash), Run: "scripts/postcommand.sh", }, }, "preinteractive": { { - Shell: ShellTypeBash, + Shell: string(language.HookKindBash), Run: "scripts/preinteractive.sh", Interactive: true, }, @@ -69,11 +78,14 @@ 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) { ranPreHook = true - require.Equal(t, "scripts/precommand.sh", args.Args[0]) + require.Equal(t, filepath.ToSlash( + filepath.Join(scriptsDir, "precommand.sh"), + ), args.Args[0]) require.Equal(t, cwd, args.Cwd) require.ElementsMatch(t, env.Environ(), args.Env) require.Equal(t, false, args.Interactive) @@ -104,11 +116,14 @@ 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) { ranPostHook = true - require.Equal(t, "scripts/postcommand.sh", args.Args[0]) + require.Equal(t, filepath.ToSlash( + filepath.Join(scriptsDir, "postcommand.sh"), + ), args.Args[0]) require.Equal(t, cwd, args.Cwd) require.ElementsMatch(t, env.Environ(), args.Env) require.Equal(t, false, args.Interactive) @@ -139,11 +154,14 @@ 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) { ranPostHook = true - require.Equal(t, "scripts/preinteractive.sh", args.Args[0]) + require.Equal(t, filepath.ToSlash( + filepath.Join(scriptsDir, "preinteractive.sh"), + ), args.Args[0]) require.Equal(t, cwd, args.Cwd) require.ElementsMatch(t, env.Environ(), args.Env) require.Equal(t, true, args.Interactive) @@ -174,6 +192,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) { @@ -200,50 +219,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 @@ -252,12 +227,15 @@ 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) { 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 }) @@ -267,7 +245,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 }) @@ -304,7 +284,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) @@ -316,38 +299,184 @@ 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\"", + // Create script files on disk for validation. + require.NoError(t, os.MkdirAll(filepath.Join(cwd, "scripts"), osutil.PermissionDirectory)) + require.NoError(t, os.WriteFile( + filepath.Join(cwd, "scripts", "script.sh"), nil, osutil.PermissionExecutableFile, + )) + require.NoError(t, os.WriteFile( + filepath.Join(cwd, "scripts", "script.ps1"), nil, osutil.PermissionExecutableFile, + )) + + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) + + t.Run("BashHookExecutes", func(t *testing.T) { + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Shell: string(language.HookKindBash), + Run: "scripts/script.sh", + }}, + } + + shellRan := false + mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "script.sh") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + shellRan = true + return exec.NewRunResult(0, "", ""), nil + }) + + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) + runner := NewHooksRunner( + hooksManager, mockContext.CommandRunner, envManager, + mockContext.Console, cwd, hooksMap, env, mockContext.Container, + ) + + err := runner.RunHooks(*mockContext.Context, HookTypePre, nil, "deploy") + require.NoError(t, err) + require.True(t, shellRan) + }) + + t.Run("PowershellHookExecutes", func(t *testing.T) { + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: "scripts/script.ps1", + }}, + } + + shellRan := false + mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + mockContext.CommandRunner.MockToolInPath("pwsh", nil) + + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "script.ps1") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + shellRan = true + require.Equal(t, "pwsh", args.Cmd) + return exec.NewRunResult(0, "", ""), nil + }) + + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) + runner := NewHooksRunner( + hooksManager, mockContext.CommandRunner, envManager, + mockContext.Console, cwd, hooksMap, env, mockContext.Container, + ) + + err := runner.RunHooks(*mockContext.Context, HookTypePre, nil, "deploy") + require.NoError(t, err) + require.True(t, shellRan) + }) + + t.Run("InlineBashHookExecutes", func(t *testing.T) { + hooksMap := map[string][]*HookConfig{ + "preinline": {{ + Name: "preinline", + Shell: string(language.HookKindBash), + Run: "echo 'Hello'", + }}, + } + + inlineRan := false + mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "preinline") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + inlineRan = true + return exec.NewRunResult(0, "", ""), nil + }) + + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) + runner := NewHooksRunner( + hooksManager, mockContext.CommandRunner, envManager, + mockContext.Console, cwd, hooksMap, env, mockContext.Container, + ) + + err := runner.RunHooks(*mockContext.Context, HookTypePre, nil, "inline") + require.NoError(t, err) + require.True(t, inlineRan) + }) + + t.Run("MissingRunReturnsError", func(t *testing.T) { + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Shell: string(language.HookKindBash), + }}, + } + + mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) + runner := NewHooksRunner( + hooksManager, mockContext.CommandRunner, envManager, + mockContext.Console, cwd, hooksMap, env, mockContext.Container, + ) + + err := runner.RunHooks(*mockContext.Context, HookTypePre, nil, "deploy") + require.Error(t, err) + require.ErrorIs(t, err, ErrRunRequired) + }) +} + +// Test_ExecHook_NonShellHooks verifies the integration between +// [HooksRunner] and [tools.HookExecutor] for non-shell hooks. +func Test_ExecHook_NonShellHooks(t *testing.T) { + t.Run("PythonHook", func(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + env := environment.NewWithValues("test", map[string]string{ + "FOO": "bar", + }) + + // Create a .py script file on disk so validate() sees it. + scriptDir := filepath.Join(cwd, "hooks") + require.NoError(t, os.MkdirAll(scriptDir, osutil.PermissionDirectory)) + scriptFile := filepath.Join(scriptDir, "predeploy.py") + require.NoError(t, os.WriteFile(scriptFile, nil, osutil.PermissionExecutableFile)) + + hooksMap := map[string][]*HookConfig{ + "predeploy": { + { + Name: "predeploy", + Run: filepath.Join("hooks", "predeploy.py"), + }, }, - }, - } + } - ensureScriptsExist(t, hooksMap) + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) - envManager := &mockenv.MockEnvManager{} + prepareRan := false + executeRan := false - t.Run("Bash", func(t *testing.T) { - hookConfig := hooksMap["bash"][0] mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + + // Mock the Python version check issued by python.Cli.CheckInstalled + // via tools.ExecuteCommand → commandRunner.Run. + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "--version") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + prepareRan = true + return exec.NewRunResult(0, "Python 3.11.0", ""), nil + }) + + // Mock the actual Python script execution. + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + executeRan = true + return exec.NewRunResult(0, "", ""), nil + }) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, @@ -360,17 +489,50 @@ func Test_Hooks_GetScript(t *testing.T) { mockContext.Container, ) - script, err := runner.GetScript(hookConfig, runner.env.Environ()) - require.NotNil(t, script) - require.Equal(t, "*bash.bashScript", reflect.TypeOf(script).String()) - require.Equal(t, ScriptLocationPath, hookConfig.location) - require.Equal(t, ShellTypeBash, hookConfig.Shell) + err := runner.RunHooks( + *mockContext.Context, HookTypePre, nil, "deploy", + ) + require.NoError(t, err) + require.True(t, prepareRan, "Prepare (version check) should have run") + require.True(t, executeRan, "Execute should have run the .py script") }) - t.Run("Powershell", func(t *testing.T) { - hookConfig := hooksMap["pwsh"][0] + t.Run("ShellHookAbsolutePath", 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: string(language.HookKindBash), + Run: "scripts/predeploy.sh", + }, + }, + } + ensureScriptsExist(t, hooksMap) + + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) + + shellRan := false + mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "predeploy.sh") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + shellRan = true + require.Equal(t, filepath.ToSlash( + filepath.Join(cwd, "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, @@ -383,20 +545,50 @@ func Test_Hooks_GetScript(t *testing.T) { mockContext.Container, ) - script, err := runner.GetScript(hookConfig, runner.env.Environ()) - require.NotNil(t, script) - require.Equal(t, "*powershell.powershellScript", reflect.TypeOf(script).String()) - require.Equal(t, ScriptLocationPath, hookConfig.location) - require.Equal(t, ShellTypePowershell, hookConfig.Shell) + err := runner.RunHooks( + *mockContext.Context, HookTypePre, nil, "deploy", + ) + require.NoError(t, err) + require.True(t, shellRan, "Bash executor should use absolute path for .sh hooks") }) - t.Run("Inline Script", func(t *testing.T) { - tempDir := t.TempDir() - ostest.Chdir(t, tempDir) + t.Run("NonShellHookPrepareFailure", func(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + env := environment.NewWithValues("test", map[string]string{}) + + scriptDir := filepath.Join(cwd, "hooks") + require.NoError(t, os.MkdirAll(scriptDir, osutil.PermissionDirectory)) + scriptFile := filepath.Join(scriptDir, "predeploy.py") + require.NoError(t, os.WriteFile( + scriptFile, nil, osutil.PermissionExecutableFile, + )) + + hooksMap := map[string][]*HookConfig{ + "predeploy": { + { + Name: "predeploy", + Run: filepath.Join("hooks", "predeploy.py"), + }, + }, + } + + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) - hookConfig := hooksMap["inline"][0] mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + + // Simulate Python not being installed — version check + // fails with an error. + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "--version") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult(1, "", ""), fmt.Errorf("python not found") + }) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, @@ -409,22 +601,125 @@ func Test_Hooks_GetScript(t *testing.T) { mockContext.Container, ) - script, err := runner.GetScript(hookConfig, runner.env.Environ()) - require.NotNil(t, script) - require.Equal(t, "*bash.bashScript", reflect.TypeOf(script).String()) - require.Equal(t, ScriptLocationInline, hookConfig.location) - require.Equal(t, ShellTypeBash, hookConfig.Shell) - require.Equal(t, "echo 'hello'", hookConfig.script) - require.Empty(t, hookConfig.path) - require.NoError(t, err) + err := runner.RunHooks( + *mockContext.Context, HookTypePre, nil, "deploy", + ) + + require.Error(t, err) + require.Contains(t, err.Error(), "preparing hook") + }) + + t.Run("NonShellHookExecuteFailure", 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()) + registerHookExecutors(mockContext) + + // Prepare succeeds (version check passes). + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "--version") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult(0, "Python 3.11.0", ""), nil + }) + + // Execute fails (script returns non-zero exit code). + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult(1, "", "error"), fmt.Errorf("script failed") + }) + + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) + runner := NewHooksRunner( + hooksManager, + 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("Inline With Url", func(t *testing.T) { - tempDir := t.TempDir() - ostest.Chdir(t, tempDir) + t.Run("NonShellHookEnvVars", 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 - hookConfig := hooksMap["inlineWithUrl"][0] mockContext := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockContext) + + // 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, @@ -437,115 +732,326 @@ func Test_Hooks_GetScript(t *testing.T) { mockContext.Container, ) - script, err := runner.GetScript(hookConfig, runner.env.Environ()) - require.NotNil(t, script) - require.Equal(t, "*powershell.powershellScript", reflect.TypeOf(script).String()) - require.Equal(t, ScriptLocationInline, hookConfig.location) - require.Equal(t, ShellTypePowershell, hookConfig.Shell) - require.Contains( - t, - hookConfig.script, - "Invoke-WebRequest -Uri \"https://sample.com/sample.json\" -OutFile \"out.json\"", + err := runner.RunHooks( + *mockContext.Context, HookTypePre, nil, "deploy", ) - require.Empty(t, hookConfig.path) + require.NoError(t, err) - }) + require.NotEmpty(t, capturedEnv) + // The environment variables from the hook's env should + // be forwarded to the executor. + envMap := envSliceToMap(capturedEnv) + require.Equal(t, "my_value", envMap["MY_VAR"]) + require.Equal(t, "other_value", envMap["OTHER_VAR"]) + }) } -type scriptValidationTest struct { - name string - config *HookConfig - expectedError error - createFile bool +// 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 } -func Test_GetScript_Validation(t *testing.T) { - tempDir := t.TempDir() - ostest.Chdir(t, tempDir) +// 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) - err := os.WriteFile("my-script.ps1", nil, osutil.PermissionFile) - require.NoError(t, err) + env := environment.NewWithValues("test", map[string]string{}) - env := environment.New("test") - envManager := &mockenv.MockEnvManager{} + // 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"), + }, + }, + } - 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, - ) + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) + + var capturedScriptPath string + var capturedCwd string - scriptValidations := []scriptValidationTest{ - { - name: "Missing Script Type - Should Use Default Shell", - config: &HookConfig{ - Name: "test1", - Run: "echo 'Hello'", + 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") }, - expectedError: nil, // Should no longer error, should use default shell - }, - { - name: "Missing Run param", - config: &HookConfig{ - Name: "test2", - Shell: ShellTypeBash, + ).RespondFn( + func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult( + 0, "Python 3.11.0", "", + ), nil }, - expectedError: ErrRunRequired, - }, - { - name: "Unsupported Script Type", - config: &HookConfig{ - Name: "test4", - Run: "my-script.go", + ) + + // Mock the actual script execution and capture paths. + mockContext.CommandRunner.When( + func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "main.py") }, - expectedError: ErrUnsupportedScriptType, - createFile: true, - }, - { - name: "Valid External Script", - config: &HookConfig{ - Name: "test5", - Run: "my-script.ps1", + ).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 }, - createFile: true, - }, - { - name: "Valid Inline", - config: &HookConfig{ - Name: "test5", - Shell: ShellTypeBash, - Run: "echo 'Hello'", + ) + + 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", + ) + }) - for _, test := range scriptValidations { - if test.createFile { - ensureScriptsExist( - t, - map[string][]*HookConfig{ - "test": {test.config}, + 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", + ), }, - ) + }, } - 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) - } - }) - } + 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 9f2dc24b849..1747fdfbdb2 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -17,21 +17,18 @@ import ( "strings" "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "github.com/azure/azure-dev/cli/azd/pkg/tools/language" ) -// The type of hooks. Supported values are 'pre' and 'post' +// HookType represents the execution timing of a hook relative to the +// associated command. Supported values are 'pre' and 'post'. type HookType string + +// HookPlatformType identifies the operating system platform for +// platform-specific hook overrides. type HookPlatformType string -type ShellType string -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 @@ -42,35 +39,75 @@ const ( ) var ( + // 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 script type. Ensure 'Shell' parameter is set in configuration options", + "unable to determine hook kind. " + + "Ensure 'kind' or 'shell' 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 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) " + + "or 'shell' (e.g. shell: sh)", ) - 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. +// Every hook is executed through a [tools.HookExecutor] resolved via IoC +// based on the hook's Kind — 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 + // 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"` - // The type of script hook (bash or powershell) - Shell ShellType `yaml:"shell,omitempty"` + // 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 + // 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"` + // 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 hook execution, + // used as the project context for dependency installation and builds. + // When empty, defaults to the directory containing the script + // referenced by the run field. Only set this when the project root + // differs from the script's directory. + Dir string `yaml:"dir,omitempty" json:"dir,omitempty"` // The inline script to execute or path to existing file Run string `yaml:"run,omitempty"` // When set to true will not halt command execution even when a script error occurs. @@ -86,8 +123,18 @@ 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) and ensures that Kind +// is always resolved to a concrete [language.HookKind] value. +// +// Kind resolution priority: +// 1. Explicit Kind field +// 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. func (hc *HookConfig) validate() error { if hc.validated { return nil @@ -97,71 +144,228 @@ func (hc *HookConfig) validate() error { return ErrRunRequired } - relativeCheckPath := strings.ReplaceAll(hc.Run, "/", string(os.PathSeparator)) + // 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), + ) + + // 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, hc.Run) + if filepath.IsAbs(relativeCheckPath) { + fullCheckPath = relativeCheckPath + } else if dirExplicit { + 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 { + fullCheckPath = filepath.Join( + hc.cwd, relativeCheckPath, + ) + } + } else { + fullCheckPath = filepath.Join( + hc.cwd, relativeCheckPath, + ) + } } stats, err := os.Stat(fullCheckPath) if err == nil && !stats.IsDir() { - hc.location = ScriptLocationPath hc.path = relativeCheckPath + if foundViaDir { + dirExplicit = true + } } else { - hc.location = ScriptLocationInline hc.script = hc.Run + dirExplicit = false } - if hc.Shell == ScriptTypeUnknown { - if hc.location == ScriptLocationInline { - hc.Shell = getDefaultShellForOS() + // Kind resolution — priority: + // 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) + } + 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.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.Kind, + ) + } + + // Non-shell executors handle their own runtime and dependency + // setup; no shell type resolution or temp script is needed. + 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 != "" { + hc.Dir = filepath.Dir(hc.path) + } + } 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 - } else { - scriptType, err := inferScriptTypeFromFilePath(hc.path) - if err != nil { - return err - } + } - hc.Shell = scriptType + // 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, + ) } } - hc.validated = true + // ── Resolve absolute paths ────────────────────────────── + // After this point, resolvedDir and resolvedScriptPath are + // the single source of truth for hook execution paths. + // execHook() should read these directly. - return nil -} + boundaryDir := hc.cwd + if boundaryDir == "" { + boundaryDir, _ = os.Getwd() + } -// 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 + // 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 } - if hc.location != ScriptLocationInline { - return hc.path, nil, nil + // 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 + } } - tempScript, err := createTempScript(hc) - if err != nil { - return "", nil, err + // 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( + "hook directory %q escapes project root %q", + hc.Dir, boundaryDir, + ) } + hc.resolvedDir = absDir - return tempScript, func() { - _ = os.Remove(tempScript) - }, nil + // 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 } -// IsPowerShellHook determines if a hook configuration uses PowerShell +// IsPowerShellHook determines if a hook configuration uses PowerShell. +// 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 { - // Check if shell is explicitly set to pwsh - if hc.Shell == ShellTypePowershell { + if hc.Kind == language.HookKindPowerShell { + 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 } @@ -170,7 +374,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() } @@ -183,6 +388,9 @@ func (hc *HookConfig) IsUsingDefaultShell() bool { return hc.usingDefaultShell } +// 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 { @@ -226,10 +434,14 @@ func appendHookConfigSignature(builder *strings.Builder, hookConfig *HookConfig) return } - builder.WriteString(string(hookConfig.Shell)) + 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)) @@ -246,84 +458,11 @@ func appendHookConfigSignature(builder *strings.Builder, hookConfig *HookConfig) appendHookConfigSignature(builder, hookConfig.Posix) } -// getDefaultShellForOS returns the default shell type based on the operating system -func getDefaultShellForOS() ShellType { +// defaultKindForOS returns the default shell kind for the +// current operating system: PowerShell on Windows, Bash elsewhere. +func defaultKindForOS() language.HookKind { if runtime.GOOS == "windows" { - return ShellTypePowershell - } - return ShellTypeBash -} - -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, - ) - } -} - -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)) + return language.HookKindPowerShell } - - 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 + return language.HookKindBash } diff --git a/cli/azd/pkg/ext/models_test.go b/cli/azd/pkg/ext/models_test.go new file mode 100644 index 00000000000..de8449e61de --- /dev/null +++ b/cli/azd/pkg/ext/models_test.go @@ -0,0 +1,766 @@ +// 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_KindField(t *testing.T) { + tests := []struct { + name string + yamlInput string + expectedKind language.HookKind + expectedDir string + }{ + { + name: "OmittedKindDefaultsToUnknown", + yamlInput: "run: scripts/hook.sh\n", + expectedKind: language.HookKindUnknown, + expectedDir: "", + }, + { + name: "OmittedDirDefaultsToEmpty", + yamlInput: "run: scripts/hook.py\nkind: python\n", + expectedKind: language.HookKindPython, + expectedDir: "", + }, + { + name: "KindPython", + yamlInput: "run: scripts/hook.py\nkind: python\ndir: src/myapp\n", + expectedKind: language.HookKindPython, + expectedDir: "src/myapp", + }, + { + name: "KindJavaScript", + yamlInput: "run: hooks/prebuild.js\nkind: js\ndir: hooks\n", + expectedKind: language.HookKindJavaScript, + expectedDir: "hooks", + }, + { + name: "KindTypeScript", + yamlInput: "run: hooks/deploy.ts\nkind: ts\n", + expectedKind: language.HookKindTypeScript, + expectedDir: "", + }, + { + name: "KindDotNet", + yamlInput: "run: hooks/validate.csx\nkind: dotnet\ndir: hooks/dotnet\n", + expectedKind: language.HookKindDotNet, + expectedDir: "hooks/dotnet", + }, + { + name: "KindBash", + yamlInput: "run: scripts/setup.sh\nkind: sh\n", + expectedKind: language.HookKindBash, + 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" + + "kind: python\ndir: src/hooks\ncontinueOnError: true\n", + expectedKind: language.HookKindPython, + 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.expectedKind, config.Kind) + require.Equal(t, tt.expectedDir, config.Dir) + }) + } +} + +func TestHookConfig_KindRoundTrip(t *testing.T) { + original := HookConfig{ + Run: "hooks/deploy.py", + Kind: language.HookKindPython, + 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.Kind, decoded.Kind) + require.Equal(t, original.Dir, decoded.Dir) + require.Equal(t, original.Run, decoded.Run) +} + +func TestHookKind_Constants(t *testing.T) { + tests := []struct { + name string + kind language.HookKind + expected string + }{ + {"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.kind)) + }) + } +} + +func TestHookConfig_ValidateKindResolution(t *testing.T) { + tests := []struct { + name string + config HookConfig + createFile string // relative path to create under cwd + expectedKind language.HookKind + isShell bool + expectError string + }{ + { + name: "ExplicitKindPythonFromFile", + config: HookConfig{ + Name: "test", + Kind: language.HookKindPython, + Run: "script.py", + }, + createFile: "script.py", + expectedKind: language.HookKindPython, + isShell: false, + }, + { + name: "ExplicitKindOverridesExtension", + config: HookConfig{ + Name: "test", + Kind: language.HookKindPython, + Run: "script.js", + }, + createFile: "script.js", + expectedKind: language.HookKindPython, + isShell: false, + }, + { + name: "ShellAliasBashMapsToKind", + config: HookConfig{ + Name: "test", + Shell: string(language.HookKindBash), + Run: "echo hello", + }, + expectedKind: language.HookKindBash, + isShell: true, + }, + { + name: "InferPythonFromExtension", + config: HookConfig{ + Name: "test", + Run: "hooks/seed.py", + }, + createFile: "hooks/seed.py", + expectedKind: language.HookKindPython, + isShell: false, + }, + { + name: "InferJavaScriptFromExtension", + config: HookConfig{ + Name: "test", + Run: "hooks/setup.js", + }, + createFile: "hooks/setup.js", + expectedKind: language.HookKindJavaScript, + isShell: false, + }, + { + name: "InferTypeScriptFromExtension", + config: HookConfig{ + Name: "test", + Run: "hooks/test.ts", + }, + createFile: "hooks/test.ts", + expectedKind: language.HookKindTypeScript, + isShell: false, + }, + { + name: "InferDotNetFromExtension", + config: HookConfig{ + Name: "test", + Run: "hooks/run.cs", + }, + createFile: "hooks/run.cs", + expectedKind: language.HookKindDotNet, + isShell: false, + }, + { + name: "InferBashFromExtension", + config: HookConfig{ + Name: "test", + Run: "hooks/deploy.sh", + }, + createFile: "hooks/deploy.sh", + expectedKind: language.HookKindBash, + isShell: true, + }, + { + name: "InferPowerShellFromExtension", + config: HookConfig{ + Name: "test", + Run: "hooks/deploy.ps1", + }, + createFile: "hooks/deploy.ps1", + expectedKind: language.HookKindPowerShell, + isShell: true, + }, + { + name: "InlineScriptDefaultsToOSShell", + config: HookConfig{ + Name: "test", + Run: "echo hello", + }, + expectedKind: defaultKindForOS(), + isShell: true, + }, + { + name: "InlineScriptWithKindPythonErrors", + config: HookConfig{ + Name: "test", + Kind: language.HookKindPython, + Run: "print('hello')", + }, + expectError: "inline scripts are not supported " + + "for python hooks", + }, + { + name: "InlineScriptWithShellBashIsOK", + config: HookConfig{ + Name: "test", + Shell: string(language.HookKindBash), + Run: "echo hello", + }, + expectedKind: language.HookKindBash, + isShell: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := tt.config + cwd := t.TempDir() + config.cwd = cwd + + if tt.createFile != "" { + filePath := filepath.Join(cwd, tt.createFile) + err := os.MkdirAll(filepath.Dir(filePath), 0o755) + require.NoError(t, err) + err = os.WriteFile(filePath, nil, 0o600) + require.NoError(t, err) + } + + err := config.validate() + + if tt.expectError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectError) + return + } + + require.NoError(t, err) + require.Equal( + t, tt.expectedKind, config.Kind, + ) + require.Equal( + t, tt.isShell, + config.Kind.IsShell(), + ) + }) + } +} + +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: string(language.HookKindBash), + Run: filepath.Join("hooks", "setup.sh"), + }, + createFile: filepath.Join("hooks", "setup.sh"), + expectedDir: "", + }, + { + name: "InlineScriptDirUnchanged", + config: HookConfig{ + Name: "test", + Shell: string(language.HookKindBash), + 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) + }) + } +} + +// 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 + kind language.HookKind + expected bool + }{ + {"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.kind.IsShell(), + ) + }) + } +} + +// 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") +} 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..3579b7926dd --- /dev/null +++ b/cli/azd/pkg/ext/python_hooks_e2e_test.go @@ -0,0 +1,961 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ext + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "github.com/azure/azure-dev/cli/azd/pkg/tools/language" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockenv" + "github.com/azure/azure-dev/cli/azd/test/ostest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// newPythonTestFixture creates a temp directory with the script file +// and optional requirements.txt, returning the cwd and script path. +func newPythonTestFixture( + t *testing.T, + scriptRelPath string, + withRequirements bool, +) string { + t.Helper() + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + absScript := filepath.Join(cwd, scriptRelPath) + require.NoError(t, os.MkdirAll( + filepath.Dir(absScript), osutil.PermissionDirectory, + )) + require.NoError(t, os.WriteFile( + absScript, nil, osutil.PermissionExecutableFile, + )) + + if withRequirements { + reqPath := filepath.Join( + filepath.Dir(absScript), "requirements.txt", + ) + require.NoError(t, os.WriteFile( + reqPath, []byte("flask\n"), osutil.PermissionFile, + )) + } + + return cwd +} + +// buildRunner is a compact constructor that wires up a +// [HooksRunner] from the mocked context. +func buildRunner( + t *testing.T, + mockCtx *mocks.MockContext, + cwd string, + hooks map[string][]*HookConfig, + env *environment.Environment, +) *HooksRunner { + t.Helper() + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, env).Return(nil) + + hooksManager := NewHooksManager( + cwd, mockCtx.CommandRunner, + ) + + return NewHooksRunner( + hooksManager, + mockCtx.CommandRunner, + envManager, + mockCtx.Console, + cwd, + hooks, + env, + mockCtx.Container, + ) +} + +// stubPythonVersionCheck registers a mock for the Python +// --version call that always succeeds. +func stubPythonVersionCheck( + mockCtx *mocks.MockContext, +) { + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "--version") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult( + 0, "Python 3.11.0", "", + ), nil + }) +} + +// --------------------------------------------------------------------------- +// E2E Python hook tests +// --------------------------------------------------------------------------- + +// TestPythonHook_AutoDetectFromExtension verifies that a hook with +// run: script.py (no explicit kind:) auto-detects Python and +// routes through the HookExecutor pipeline. +func TestPythonHook_AutoDetectFromExtension(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.py") + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + // Kind intentionally omitted. + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + var executedScript string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + executedScript = args.Args[0] + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + assert.Contains( + t, executedScript, "predeploy.py", + "auto-detected Python hook should execute the .py script", + ) + + // Verify the config was resolved as a non-shell hook. + hookCfg := hooksMap["predeploy"][0] + assert.Equal( + t, language.HookKindPython, hookCfg.Kind, + ) + assert.False(t, hookCfg.Kind.IsShell()) +} + +// TestPythonHook_ExplicitKind verifies that kind: python +// in the config uses the Python executor even when the script has +// no .py extension. +func TestPythonHook_ExplicitKind(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", + Kind: language.HookKindPython, + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + executed := false + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "myscript") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + executed = true + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + require.True( + t, executed, + "explicit kind: python should use Python executor", + ) +} + +// TestPythonHook_EnvVarsPassthrough verifies that azd +// environment variables are forwarded to the Python executor. +func TestPythonHook_EnvVarsPassthrough(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.py") + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues("test", map[string]string{ + "AZURE_ENV_NAME": "dev", + "AZURE_LOCATION": "eastus2", + "MY_CUSTOM_SETTING": "custom_value", + }) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + var capturedEnv []string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + capturedEnv = args.Env + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + require.NotEmpty(t, capturedEnv) + + envMap := envSliceToMap(capturedEnv) + assert.Equal(t, "dev", envMap["AZURE_ENV_NAME"]) + assert.Equal(t, "eastus2", envMap["AZURE_LOCATION"]) + assert.Equal(t, "custom_value", envMap["MY_CUSTOM_SETTING"]) +} + +// TestPythonHook_WithRequirementsTxt verifies that when a +// requirements.txt exists alongside the script, the executor +// creates a venv and installs deps before running the script. +func TestPythonHook_WithRequirementsTxt(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.py") + cwd := newPythonTestFixture(t, scriptRel, true) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + callLog := []string{} + + // Mock "python -m venv …" — venv creation. + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "-m venv") || + strings.Contains(command, "venv") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + callLog = append(callLog, "create-venv") + // Create the venv directory so the executor sees it. + venvDir := filepath.Join( + args.Cwd, + args.Args[len(args.Args)-1], + ) + require.NoError(t, os.MkdirAll( + venvDir, osutil.PermissionDirectory, + )) + return exec.NewRunResult(0, "", ""), nil + }) + + // Mock "pip install -r requirements.txt". + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "install") && + strings.Contains(command, "requirements") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + callLog = append(callLog, "pip-install") + return exec.NewRunResult(0, "", ""), nil + }) + + // Mock actual script execution. + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + callLog = append(callLog, "execute") + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + + // The overall flow: version-check → venv → pip → execute. + assert.Contains(t, callLog, "create-venv") + assert.Contains(t, callLog, "pip-install") + assert.Contains(t, callLog, "execute") +} + +// TestPythonHook_StdoutCapture verifies that the hook execution +// result contains stdout from the Python script process. +func TestPythonHook_StdoutCapture(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.py") + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + // Simulate the script producing stdout. + return exec.NewRunResult( + 0, "Hello from Python hook!", "", + ), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + // RunHooks doesn't return the result directly, but + // the pipeline completes without error confirming the + // execution path ran and stdout was handled. + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + require.NoError(t, err) +} + +// TestPythonHook_NonZeroExitCode verifies that a Python hook +// returning a non-zero exit code produces an error containing +// the exit code information. +func TestPythonHook_NonZeroExitCode(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.py") + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult(1, "", "error output"), + fmt.Errorf("process exited with code 1") + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "'predeploy' hook failed") + assert.Contains(t, err.Error(), "exit code: '1'") +} + +// TestPythonHook_ContinueOnError verifies that when +// continueOnError: true is set and the Python hook fails, the +// error is swallowed and RunHooks returns nil. +func TestPythonHook_ContinueOnError(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.py") + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + ContinueOnError: true, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult(1, "", "error"), + fmt.Errorf("script error") + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + // ContinueOnError should suppress the error. + require.NoError(t, err) +} + +// TestPythonHook_ProjectLevel verifies a Python hook registered +// at the project level (pre) executes through the +// hook executor pipeline. +func TestPythonHook_ProjectLevel(t *testing.T) { + scriptRel := filepath.Join("hooks", "preprovision.py") + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "preprovision": {{ + Name: "preprovision", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + executed := false + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "preprovision.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + executed = true + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "provision", + ) + + require.NoError(t, err) + require.True(t, executed, "project-level Python hook should execute") +} + +// TestPythonHook_ServiceLevel verifies a Python hook registered +// at the service level (postdeploy for a service) executes through +// the 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") + scriptRel := filepath.Join( + serviceDir, "hooks", "postdeploy.py", + ) + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "postdeploy": {{ + Name: "postdeploy", + Run: filepath.Join( + serviceDir, "hooks", "postdeploy.py", + ), + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + var capturedCwd string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "postdeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + capturedCwd = args.Cwd + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePost, nil, "deploy", + ) + + require.NoError(t, err) + + // The execution cwd should be the script's directory. + expectedCwd := filepath.Join( + cwd, serviceDir, "hooks", + ) + assert.Equal(t, expectedCwd, capturedCwd) +} + +// TestPythonHook_ShellHookUnaffected verifies that a 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() + 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: string(language.HookKindBash), + Run: filepath.Join( + "hooks", "prebuild.sh", + ), + }}, + "predeploy": {{ + Name: "predeploy", + Run: filepath.Join( + "hooks", "predeploy.py", + ), + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + shellRan := false + pythonRan := false + + // Shell hook mock. + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "prebuild.sh") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + shellRan = true + // 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( + 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 non-shell 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 + kind language.HookKind + continueOnError bool + exitCode int + execErr error + wantErr bool + errContains string + }{ + { + name: "SuccessAutoDetect", + scriptRel: filepath.Join("hooks", "hook.py"), + exitCode: 0, + wantErr: false, + }, + { + name: "SuccessExplicitKind", + scriptRel: filepath.Join("hooks", "run"), + kind: language.HookKindPython, + 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.kind != language.HookKindUnknown { + hookCfg.Kind = tt.kind + } + + hooksMap := map[string][]*HookConfig{ + "predeploy": {hookCfg}, + } + + mockCtx := mocks.NewMockContext( + context.Background(), + ) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + // Derive the script base name for matching. + scriptBase := filepath.Base(tt.scriptRel) + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains( + command, scriptBase, + ) + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult( + tt.exitCode, "", "", + ), tt.execErr + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, + HookTypePre, nil, "deploy", + ) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains( + t, err.Error(), tt.errContains, + ) + } + } else { + require.NoError(t, err) + } + }) + } +} + +// TestPythonHook_PythonBinaryResolution verifies that the correct +// Python binary is invoked based on the platform. +func TestPythonHook_PythonBinaryResolution(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.py") + cwd := newPythonTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + var capturedCmd string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + capturedCmd = args.Cmd + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + require.NotEmpty(t, capturedCmd) + + // Without a venv, the executor uses system Python. + if runtime.GOOS == "windows" { + // The mock ToolInPath returns nil → "py" is preferred. + assert.True( + t, + capturedCmd == "py" || capturedCmd == "python", + "expected py or python on Windows, got %s", + capturedCmd, + ) + } else { + assert.Equal(t, "python3", capturedCmd) + } +} + +// TestPythonHook_ExplicitDirOverridesCwd verifies that +// the Dir field in HookConfig overrides the default working +// directory for hook execution. +func TestPythonHook_ExplicitDirOverridesCwd(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + // Create script and a custom directory. + scriptDir := filepath.Join(cwd, "hooks") + require.NoError(t, os.MkdirAll( + scriptDir, osutil.PermissionDirectory, + )) + require.NoError(t, os.WriteFile( + filepath.Join(scriptDir, "predeploy.py"), + nil, osutil.PermissionExecutableFile, + )) + + customDir := filepath.Join(cwd, "custom_workdir") + require.NoError(t, os.MkdirAll( + customDir, osutil.PermissionDirectory, + )) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: filepath.Join("hooks", "predeploy.py"), + Dir: customDir, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubPythonVersionCheck(mockCtx) + + var capturedCwd string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.py") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + capturedCwd = args.Cwd + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + assert.Equal( + t, customDir, capturedCwd, + "Dir should override the default working directory", + ) +} + +// TestPythonHook_InlineScriptRejected verifies that inline +// Python scripts (no file path) are rejected with a clear error +// since non-shell 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", + Kind: language.HookKindPython, + Run: "print('hello')", + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.Error(t, err) + assert.Contains( + t, err.Error(), "inline scripts are not supported", + ) +} diff --git a/cli/azd/pkg/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)}, }, } diff --git a/cli/azd/pkg/project/project_config_test.go b/cli/azd/pkg/project/project_config_test.go index dc49627d063..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: ext.ShellTypeBash, + 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: ext.ShellTypeBash, + 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: ext.ShellTypeBash, + Shell: string(language.HookKindBash), Run: "scripts/postprovision1.sh", }, { - Shell: ext.ShellTypeBash, + 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: ext.ShellTypeBash, + Shell: string(language.HookKindBash), Run: "scripts/postprovision1.sh", }, { - Shell: ext.ShellTypeBash, + 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 10c829ee5d2..4c28b97bd9b 100644 --- a/cli/azd/pkg/project/project_test.go +++ b/cli/azd/pkg/project/project_test.go @@ -15,10 +15,10 @@ 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" + "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" @@ -390,7 +390,7 @@ postbuild: expectedHooks := HooksConfig{ "prebuild": {{ Name: "", - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), Run: "./pre-build.sh", ContinueOnError: false, Interactive: false, @@ -399,7 +399,7 @@ postbuild: }}, "postbuild": {{ Name: "", - Shell: ext.ShellTypePowershell, + Shell: string(language.HookKindPowerShell), Run: "./post-build.ps1", ContinueOnError: false, Interactive: false, @@ -419,7 +419,7 @@ postbuild: Services: map[string]*ServiceConfig{}, Hooks: HooksConfig{ "prebuild": {{ - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), Run: "./pre-build.sh", }}, }, @@ -455,7 +455,7 @@ postbuild: expectedHooks := HooksConfig{ "prebuild": {{ Name: "", - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), Run: "./pre-build.sh", ContinueOnError: false, Interactive: false, @@ -463,7 +463,7 @@ postbuild: Posix: nil, }, { Name: "", - Shell: ext.ShellTypeBash, + Shell: string(language.HookKindBash), Run: "./pre-build-external.sh", ContinueOnError: false, Interactive: false, @@ -472,7 +472,7 @@ postbuild: }}, "postbuild": {{ Name: "", - Shell: ext.ShellTypePowershell, + Shell: string(language.HookKindPowerShell), Run: "./post-build.ps1", ContinueOnError: false, Interactive: false, diff --git a/cli/azd/pkg/tools/bash/bash.go b/cli/azd/pkg/tools/bash/bash.go index dcec5c98076..2c4e28a5879 100644 --- a/cli/azd/pkg/tools/bash/bash.go +++ b/cli/azd/pkg/tools/bash/bash.go @@ -5,6 +5,7 @@ package bash import ( "context" + "os" "runtime" "strings" @@ -12,24 +13,50 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/tools" ) -// Creates a new BashScript command runner -func NewBashScript(commandRunner exec.CommandRunner, cwd string, envVars []string) tools.Script { - return &bashScript{ - commandRunner: commandRunner, - cwd: cwd, - envVars: envVars, - } +// NewExecutor creates a bash HookExecutor. Takes only IoC-injectable deps. +func NewExecutor(commandRunner exec.CommandRunner) tools.HookExecutor { + return &bashExecutor{commandRunner: commandRunner} } -type bashScript struct { +type bashExecutor struct { commandRunner exec.CommandRunner - cwd string - envVars []string + tempFile string // temp script created from inline content } -// Executes the specified bash script -// When interactive is true will attach to stdin, stdout & stderr -func (bs *bashScript) Execute(ctx context.Context, path string, options tools.ExecOptions) (exec.RunResult, error) { +// Prepare 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 + } + + content := "#!/bin/sh\nset -e\n\n" + + "# Auto generated file from Azure Developer CLI\n" + + execCtx.InlineScript + "\n" + + path, err := tools.CreateInlineTempScript( + execCtx.HookName, ".sh", content, + ) + if err != nil { + return err + } + b.tempFile = path + + return nil +} + +// 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, "\\", "/") @@ -41,17 +68,27 @@ 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) +} + +// 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/bash/bash_test.go b/cli/azd/pkg/tools/bash/bash_test.go index e84346f4e3f..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" @@ -23,6 +26,58 @@ func Test_Bash_Execute(t *testing.T) { "b=banana", } + t.Run("Prepare", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + executor := NewExecutor(mockContext.CommandRunner) + execCtx := tools.ExecutionContext{Cwd: workingDir, EnvVars: env} + err := executor.Prepare(*mockContext.Context, scriptPath, execCtx) + require.NoError(t, err) + }) + + t.Run("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()) @@ -42,11 +97,16 @@ func Test_Bash_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - bashScript := NewBashScript(mockContext.CommandRunner, workingDir, env) - runResult, err := bashScript.Execute( + executor := NewExecutor(mockContext.CommandRunner) + execCtx := tools.ExecutionContext{ + Cwd: workingDir, + EnvVars: env, + Interactive: new(true), + } + runResult, err := executor.Execute( *mockContext.Context, scriptPath, - tools.ExecOptions{Interactive: new(true)}, + execCtx, ) require.NotNil(t, runResult) @@ -62,11 +122,16 @@ func Test_Bash_Execute(t *testing.T) { return exec.NewRunResult(1, "", "error message"), errors.New("error message") }) - bashScript := NewBashScript(mockContext.CommandRunner, workingDir, env) - runResult, err := bashScript.Execute( + executor := NewExecutor(mockContext.CommandRunner) + execCtx := tools.ExecutionContext{ + Cwd: workingDir, + EnvVars: env, + Interactive: new(true), + } + runResult, err := executor.Execute( *mockContext.Context, scriptPath, - tools.ExecOptions{Interactive: new(true)}, + execCtx, ) require.Equal(t, 1, runResult.ExitCode) @@ -75,10 +140,14 @@ func Test_Bash_Execute(t *testing.T) { tests := []struct { name string - value tools.ExecOptions + value tools.ExecutionContext }{ - {name: "Interactive", value: tools.ExecOptions{Interactive: new(true)}}, - {name: "NonInteractive", value: tools.ExecOptions{Interactive: new(false)}}, + {name: "Interactive", value: tools.ExecutionContext{ + Cwd: workingDir, EnvVars: env, Interactive: new(true), + }}, + {name: "NonInteractive", value: tools.ExecutionContext{ + Cwd: workingDir, EnvVars: env, Interactive: new(false), + }}, } for _, test := range tests { @@ -92,8 +161,8 @@ func Test_Bash_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - bashScript := NewBashScript(mockContext.CommandRunner, workingDir, env) - runResult, err := bashScript.Execute(*mockContext.Context, scriptPath, test.value) + executor := NewExecutor(mockContext.CommandRunner) + runResult, err := executor.Execute(*mockContext.Context, scriptPath, test.value) require.NotNil(t, runResult) require.NoError(t, err) diff --git a/cli/azd/pkg/tools/language/executor.go b/cli/azd/pkg/tools/language/executor.go new file mode 100644 index 00000000000..4b3f45165bc --- /dev/null +++ b/cli/azd/pkg/tools/language/executor.go @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "path/filepath" + "strings" +) + +// 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 HookKind string + +const ( + // HookKindUnknown indicates the kind could not be + // determined from the file extension or explicit configuration. + 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. + HookKindJavaScript HookKind = "js" + // HookKindTypeScript identifies TypeScript scripts (.ts files). + // Not yet supported — IoC resolution will fail with a descriptive error. + 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. + HookKindDotNet HookKind = "dotnet" +) + +// 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 (k HookKind) IsShell() bool { + return k == HookKindBash || + k == HookKindPowerShell +} + +// InferKindFromPath determines the [HookKind] from the +// file extension of the given path. Extension matching is +// case-insensitive. The following extensions are recognized: +// +// - .py → [HookKindPython] +// - .js → [HookKindJavaScript] +// - .ts → [HookKindTypeScript] +// - .cs → [HookKindDotNet] +// - .sh → [HookKindBash] +// - .ps1 → [HookKindPowerShell] +// +// Returns [HookKindUnknown] for unrecognized extensions. +func InferKindFromPath(path string) HookKind { + ext := strings.ToLower(filepath.Ext(path)) + + switch ext { + case ".py": + return HookKindPython + case ".js": + return HookKindJavaScript + case ".ts": + return HookKindTypeScript + case ".cs": + return HookKindDotNet + case ".sh": + return HookKindBash + case ".ps1": + return HookKindPowerShell + default: + return HookKindUnknown + } +} 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..dfbdea2ea6e --- /dev/null +++ b/cli/azd/pkg/tools/language/executor_test.go @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInferKindFromPath(t *testing.T) { + tests := []struct { + name string + path string + expected HookKind + }{ + { + name: "Python", + path: "hooks/pre-deploy.py", + expected: HookKindPython, + }, + { + name: "JavaScript", + path: "hooks/pre-deploy.js", + expected: HookKindJavaScript, + }, + { + name: "TypeScript", + path: "hooks/pre-deploy.ts", + expected: HookKindTypeScript, + }, + { + name: "DotNet", + path: "hooks/pre-deploy.cs", + expected: HookKindDotNet, + }, + { + name: "Bash", + path: "hooks/pre-deploy.sh", + expected: HookKindBash, + }, + { + name: "PowerShell", + path: "hooks/pre-deploy.ps1", + expected: HookKindPowerShell, + }, + { + name: "UnknownTxt", + path: "hooks/readme.txt", + expected: HookKindUnknown, + }, + { + name: "UnknownGo", + path: "hooks/main.go", + expected: HookKindUnknown, + }, + { + name: "NoExtension", + path: "hooks/Makefile", + expected: HookKindUnknown, + }, + { + name: "EmptyPath", + path: "", + expected: HookKindUnknown, + }, + { + name: "CaseInsensitivePY", + path: "hooks/deploy.PY", + expected: HookKindPython, + }, + { + name: "CaseInsensitiveJs", + path: "hooks/deploy.Js", + expected: HookKindJavaScript, + }, + { + name: "CaseInsensitivePS1", + path: "hooks/deploy.PS1", + expected: HookKindPowerShell, + }, + { + name: "MultipleDots", + path: "hooks/pre.deploy.py", + expected: HookKindPython, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + 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 new file mode 100644 index 00000000000..5a5caf40cdc --- /dev/null +++ b/cli/azd/pkg/tools/language/project_discovery.go @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package language provides project file discovery and dependency +// management for multi-language hook scripts. +package language + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +// ProjectContext holds metadata about a discovered project file, +// used to determine how to install dependencies for a hook script. +type ProjectContext struct { + // ProjectDir is the directory containing the project file. + ProjectDir string + // DependencyFile is the absolute path to the dependency file + // (e.g. requirements.txt, package.json, *.csproj). + DependencyFile string + // Language is the 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 HookKind // inferred hook kind + IsGlob bool // true for patterns like "*.*proj" +} + +// knownProjectFiles defines project files to search for, in priority order. +// The first match found in a directory wins. +// +// Python: pyproject.toml is preferred over requirements.txt, matching the +// convention in framework_service_python.go and internal/appdetect/python.go +// (PEP 621 preference). +// +// NOTE: This is intentionally separate from internal/appdetect/ which walks +// DOWN a tree to detect service projects. Hook discovery walks UP from a +// script to find the nearest project context. +var knownProjectFiles = []projectFileEntry{ + {Name: "pyproject.toml", Language: 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 +// 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..615463efc21 --- /dev/null +++ b/cli/azd/pkg/tools/language/project_discovery_test.go @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscoverProjectFile_Python(t *testing.T) { + dir := t.TempDir() + scriptPath := filepath.Join(dir, "hook.py") + projectFile := filepath.Join(dir, "requirements.txt") + + writeFile(t, projectFile, "flask\n") + + result, err := DiscoverProjectFile(scriptPath, dir) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, dir, result.ProjectDir) + assert.Equal(t, projectFile, result.DependencyFile) + assert.Equal(t, HookKindPython, 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, HookKindPython, 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, HookKindJavaScript, 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, HookKindDotNet, 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, HookKindDotNet, 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, HookKindPython, 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, HookKindPython, result.Language, + "requirements.txt has higher priority than package.json") + assert.Equal(t, reqFile, result.DependencyFile) +} + +func TestDiscoverProjectFile_PyprojectOverRequirements(t *testing.T) { + // When both pyproject.toml and requirements.txt exist in the + // same directory, pyproject.toml wins — matching the convention + // in framework_service_python.go and internal/appdetect/python.go. + dir := t.TempDir() + scriptPath := filepath.Join(dir, "hook.py") + + pyprojectFile := filepath.Join(dir, "pyproject.toml") + writeFile(t, pyprojectFile, "[project]\nname = \"demo\"\n") + writeFile(t, filepath.Join(dir, "requirements.txt"), "flask\n") + + result, err := DiscoverProjectFile(scriptPath, dir) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, HookKindPython, result.Language) + assert.Equal(t, pyprojectFile, result.DependencyFile, + "pyproject.toml should win over requirements.txt (PEP 621)") +} + +func TestDiscoverProjectFile_WalkUpMultipleLevels(t *testing.T) { + // Script is nested several levels deep; project file is at root. + // + // root/ + // package.json + // a/ + // b/ + // hook.js <- script starts here + root := t.TempDir() + deep := filepath.Join(root, "a", "b") + require.NoError(t, os.MkdirAll(deep, 0o700)) + + projectFile := filepath.Join(root, "package.json") + writeFile(t, projectFile, "{}\n") + + scriptPath := filepath.Join(deep, "hook.js") + + result, err := DiscoverProjectFile(scriptPath, root) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, root, result.ProjectDir) + assert.Equal(t, projectFile, result.DependencyFile) + assert.Equal(t, HookKindJavaScript, 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, HookKindJavaScript, 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..2f8d55ec322 --- /dev/null +++ b/cli/azd/pkg/tools/language/python_executor.go @@ -0,0 +1,416 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/tools" + "github.com/azure/azure-dev/cli/azd/pkg/tools/python" +) + +// pythonTools abstracts the Python CLI operations needed by +// pythonExecutor, decoupling it from the concrete [python.Cli] +// for testability. [python.Cli] satisfies this interface. +type pythonTools interface { + CheckInstalled(ctx context.Context) error + CreateVirtualEnv( + ctx context.Context, + workingDir, name string, + env []string, + ) error + InstallRequirements( + ctx context.Context, + workingDir, environment, requirementFile string, + env []string, + ) error + InstallProject( + ctx context.Context, + workingDir, environment string, + env []string, + ) error +} + +// pythonExecutor implements [tools.HookExecutor] for Python scripts. +// It manages virtual environment creation and dependency +// installation when a project file (requirements.txt or +// pyproject.toml) is discovered near the script. +type pythonExecutor struct { + commandRunner exec.CommandRunner + pythonCli pythonTools + + // venvPath is set by Prepare when a project context with a + // dependency file is discovered. Empty means system Python. + venvPath string +} + +// NewPythonExecutor creates a Python HookExecutor. Takes only IoC-injectable deps. +func NewPythonExecutor( + commandRunner exec.CommandRunner, + pythonCli *python.Cli, +) tools.HookExecutor { + return newPythonExecutorInternal(commandRunner, pythonCli) +} + +// newPythonExecutorInternal creates a pythonExecutor using the +// pythonTools interface. This allows tests to inject mocks. +func newPythonExecutorInternal( + commandRunner exec.CommandRunner, + pythonCli pythonTools, +) *pythonExecutor { + return &pythonExecutor{ + commandRunner: commandRunner, + pythonCli: pythonCli, + } +} + +// Prepare verifies that Python is installed and, when a project +// file is found, creates a virtual environment and installs +// dependencies. The venv naming convention follows +// [framework_service_python.go]: {projectDirName}_env. +// +// 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, + execCtx tools.ExecutionContext, +) error { + // 1. Verify Python is installed. + if err := e.pythonCli.CheckInstalled(ctx); err != nil { + return fmt.Errorf( + "python 3 is required to run this hook "+ + "but was not found on PATH. Install "+ + "Python from https://www.python.org"+ + "/downloads/ : %w", + err, + ) + } + + // 2. Discover project context for dependency installation. + projCtx, err := DiscoverProjectFile( + scriptPath, execCtx.BoundaryDir, + ) + if err != nil { + return fmt.Errorf( + "discovering project file: %w", err, + ) + } + + // 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 == HookKindPython { + 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 + } + + // 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 != HookKindPython { + return nil + } + + // 4. Set up virtual environment. + venvName := venvNameForDir(projCtx.ProjectDir) + venvPath := filepath.Join( + projCtx.ProjectDir, venvName, + ) + + if err := e.ensureVenv( + ctx, projCtx.ProjectDir, + venvName, venvPath, execCtx.EnvVars, + ); err != nil { + return err + } + + // 5. Install dependencies from the discovered file. + depFile := filepath.Base(projCtx.DependencyFile) + if err := e.installDeps( + ctx, projCtx.ProjectDir, + venvName, depFile, execCtx.EnvVars, + ); err != nil { + return err + } + + e.venvPath = venvPath + return nil +} + +// ensureVenv creates the virtual environment if it does not +// already exist. If the venv directory exists, creation is +// skipped. Non-existence errors (e.g. permission denied) are +// propagated immediately. +func (e *pythonExecutor) ensureVenv( + ctx context.Context, + projectDir, venvName, venvPath string, + envVars []string, +) error { + 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 + } + + if !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf( + "virtual environment at %q is not accessible "+ + "(check file permissions): %w", + venvPath, statErr, + ) + } + + if err := e.pythonCli.CreateVirtualEnv( + ctx, projectDir, venvName, envVars, + ); err != nil { + return fmt.Errorf( + "creating python virtual environment at %q failed. "+ + "Ensure Python 3.3+ is installed with the venv module: %w", + filepath.Join(projectDir, venvName), err, + ) + } + return nil +} + +// installDeps installs Python dependencies from the given file +// into the virtual environment identified by venvName. +func (e *pythonExecutor) installDeps( + ctx context.Context, + projectDir, venvName, depFile string, + envVars []string, +) error { + switch depFile { + case "requirements.txt": + if err := e.pythonCli.InstallRequirements( + ctx, projectDir, venvName, depFile, envVars, + ); err != nil { + return fmt.Errorf( + "installing python requirements from %s. "+ + "Check that the file is valid and all packages are available: %w", + depFile, err, + ) + } + case "pyproject.toml": + if err := e.pythonCli.InstallProject( + ctx, projectDir, venvName, envVars, + ); err != nil { + return fmt.Errorf( + "installing python project from pyproject.toml. "+ + "Check the [build-system] section and ensure pip >= 21.3: %w", + err, + ) + } + } + return nil +} + +// Execute runs the Python script at the given path. When Prepare +// has configured a virtual environment, the venv's Python binary +// is used; otherwise the system Python is resolved using the +// same platform heuristics as [python.Cli]. +func (e *pythonExecutor) Execute( + ctx context.Context, + scriptPath string, + execCtx tools.ExecutionContext, +) (exec.RunResult, error) { + pyCmd := e.resolvePythonPath() + + runArgs := exec. + NewRunArgs(pyCmd, scriptPath). + WithEnv(execCtx.EnvVars) + + // Prefer configured cwd; fall back to script's directory. + cwd := execCtx.Cwd + if cwd == "" { + cwd = filepath.Dir(scriptPath) + } + runArgs = runArgs.WithCwd(cwd) + + if execCtx.Interactive != nil { + runArgs = runArgs.WithInteractive( + *execCtx.Interactive, + ) + } + if execCtx.StdOut != nil { + runArgs = runArgs.WithStdOut(execCtx.StdOut) + } + + return e.commandRunner.Run(ctx, runArgs) +} + +// 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 +// the system-level Python command. +func (e *pythonExecutor) resolvePythonPath() string { + if e.venvPath != "" { + if runtime.GOOS == "windows" { + return filepath.Join( + e.venvPath, "Scripts", "python.exe", + ) + } + return filepath.Join(e.venvPath, "bin", "python") + } + return resolvePythonCmd(e.commandRunner) +} + +// resolvePythonCmd returns the platform-appropriate Python +// command name, following the same resolution strategy as +// [python.Cli]: on Windows it prefers "py" (PEP 397 launcher), +// falling back to "python"; on other platforms it uses "python3". +func resolvePythonCmd( + commandRunner exec.CommandRunner, +) string { + if runtime.GOOS == "windows" { + // Try py launcher first (PEP 397), then python. + for _, cmd := range []string{"py", "python"} { + if commandRunner.ToolInPath(cmd) == nil { + return cmd + } + } + // Fallback even if not found — Prepare() will catch this. + return "python" + } + // Unix: python3 is the standard command. + if commandRunner.ToolInPath("python3") == nil { + return "python3" + } + // Fallback — Prepare() will catch missing Python. + return "python3" +} + +// 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. +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..72597f32ffd --- /dev/null +++ b/cli/azd/pkg/tools/language/python_executor_test.go @@ -0,0 +1,678 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/tools" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// mockPythonTools — test double for the pythonTools interface +// --------------------------------------------------------------------------- + +type mockPythonTools struct { + checkInstalledErr error + createVenvErr error + installReqErr error + installProjErr error + + createVenvCalled bool + installReqCalled bool + installProjCalled bool + + venvDir string // CreateVirtualEnv workingDir + venvName string // CreateVirtualEnv name + reqDir string // InstallRequirements workingDir + reqVenv string // InstallRequirements environment + reqFile string // InstallRequirements requirementFile + projDir string // InstallProject workingDir + projVenv string // InstallProject environment +} + +func (m *mockPythonTools) CheckInstalled( + _ context.Context, +) error { + return m.checkInstalledErr +} + +func (m *mockPythonTools) CreateVirtualEnv( + _ context.Context, + workingDir, name string, + _ []string, +) error { + m.createVenvCalled = true + m.venvDir = workingDir + m.venvName = name + return m.createVenvErr +} + +func (m *mockPythonTools) InstallRequirements( + _ context.Context, + workingDir, environment, requirementFile string, + _ []string, +) error { + m.installReqCalled = true + m.reqDir = workingDir + m.reqVenv = environment + m.reqFile = requirementFile + return m.installReqErr +} + +func (m *mockPythonTools) InstallProject( + _ context.Context, + workingDir, environment string, + _ []string, +) error { + m.installProjCalled = true + m.projDir = workingDir + m.projVenv = environment + return m.installProjErr +} + +// mockCommandRunner is a minimal mock of [exec.CommandRunner] +// used to construct test dependencies without invoking real +// processes. +type mockCommandRunner struct { + lastRunArgs exec.RunArgs + runResult exec.RunResult + runErr error + toolInPathFn func(name string) error +} + +func (m *mockCommandRunner) Run( + _ context.Context, + args exec.RunArgs, +) (exec.RunResult, error) { + m.lastRunArgs = args + return m.runResult, m.runErr +} + +func (m *mockCommandRunner) RunList( + _ context.Context, + _ []string, + _ exec.RunArgs, +) (exec.RunResult, error) { + return m.runResult, m.runErr +} + +func (m *mockCommandRunner) ToolInPath(name string) error { + if m.toolInPathFn != nil { + return m.toolInPathFn(name) + } + return nil +} + +// --------------------------------------------------------------------------- +// Prepare tests +// --------------------------------------------------------------------------- + +func TestPythonPrepare_PythonNotInstalled(t *testing.T) { + cli := &mockPythonTools{ + checkInstalledErr: errors.New("python not found"), + } + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{BoundaryDir: t.TempDir()} + err := e.Prepare(t.Context(), "/any/hook.py", execCtx) + + require.Error(t, err) + assert.Contains(t, err.Error(), "python 3 is required") + assert.ErrorIs(t, err, cli.checkInstalledErr) + assert.False(t, cli.createVenvCalled) + assert.False(t, cli.installReqCalled) +} + +func TestPythonPrepare_NoProjectFile(t *testing.T) { + dir := t.TempDir() + cli := &mockPythonTools{} + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{ + BoundaryDir: dir, + Cwd: dir, + } + scriptPath := filepath.Join(dir, "hook.py") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + assert.False(t, cli.createVenvCalled) + assert.False(t, cli.installReqCalled) + assert.False(t, cli.installProjCalled) + assert.Empty(t, e.venvPath) +} + +func TestPythonPrepare_WithRequirementsTxt(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + hooksDir := filepath.Join(projectDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0o700)) + writeFile( + t, + filepath.Join(projectDir, "requirements.txt"), + "flask\n", + ) + + cli := &mockPythonTools{} + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{BoundaryDir: root} + scriptPath := filepath.Join(hooksDir, "deploy.py") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + + // Virtual environment should be created. + assert.True(t, cli.createVenvCalled) + assert.Equal(t, projectDir, cli.venvDir) + assert.Equal(t, "myproject_env", cli.venvName) + + // Requirements should be installed. + assert.True(t, cli.installReqCalled) + assert.Equal(t, projectDir, cli.reqDir) + assert.Equal(t, "myproject_env", cli.reqVenv) + assert.Equal(t, "requirements.txt", cli.reqFile) + + // pyproject.toml path should NOT be used. + assert.False(t, cli.installProjCalled) + + // venvPath should be recorded. + expected := filepath.Join(projectDir, "myproject_env") + assert.Equal(t, expected, e.venvPath) +} + +func TestPythonPrepare_WithPyprojectToml(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + require.NoError(t, os.MkdirAll(projectDir, 0o700)) + writeFile( + t, + filepath.Join(projectDir, "pyproject.toml"), + "[project]\nname = \"demo\"\n", + ) + + cli := &mockPythonTools{} + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{BoundaryDir: root} + scriptPath := filepath.Join(projectDir, "deploy.py") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + + assert.True(t, cli.createVenvCalled) + assert.Equal(t, "myproject_env", cli.venvName) + + assert.True(t, cli.installProjCalled) + assert.Equal(t, projectDir, cli.projDir) + assert.Equal(t, "myproject_env", cli.projVenv) + + assert.False(t, cli.installReqCalled) +} + +func TestPythonPrepare_VenvAlreadyExists(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + require.NoError(t, os.MkdirAll(projectDir, 0o700)) + writeFile( + t, + filepath.Join(projectDir, "requirements.txt"), + "flask\n", + ) + + // Pre-create the venv directory to simulate an existing venv. + venvDir := filepath.Join(projectDir, "myproject_env") + require.NoError(t, os.MkdirAll(venvDir, 0o700)) + + cli := &mockPythonTools{} + e := newPythonExecutorInternal( + &mockCommandRunner{}, cli, + ) + + execCtx := tools.ExecutionContext{BoundaryDir: root} + scriptPath := filepath.Join(projectDir, "deploy.py") + err := e.Prepare(t.Context(), scriptPath, execCtx) + + require.NoError(t, err) + assert.False( + t, cli.createVenvCalled, + "should skip venv creation when directory exists", + ) + assert.True( + t, cli.installReqCalled, + "should still install requirements", + ) + assert.NotEmpty(t, e.venvPath) +} + +// --------------------------------------------------------------------------- +// Execute tests +// --------------------------------------------------------------------------- + +func TestPythonExecute_WithVenv(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + hooksDir := filepath.Join(projectDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0o700)) + writeFile( + t, + filepath.Join(projectDir, "requirements.txt"), + "flask\n", + ) + + cli := &mockPythonTools{} + runner := &mockCommandRunner{} + e := newPythonExecutorInternal(runner, cli) + + execCtx := tools.ExecutionContext{ + BoundaryDir: root, + Cwd: projectDir, + } + + scriptPath := filepath.Join(hooksDir, "deploy.py") + require.NoError(t, e.Prepare(t.Context(), scriptPath, execCtx)) + + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + // The command should use the venv's Python binary. + venvBase := filepath.Join(projectDir, "myproject_env") + if runtime.GOOS == "windows" { + assert.Equal(t, + filepath.Join( + venvBase, "Scripts", "python.exe", + ), + runner.lastRunArgs.Cmd, + ) + } else { + assert.Equal(t, + filepath.Join(venvBase, "bin", "python"), + runner.lastRunArgs.Cmd, + ) + } + + // Script path should be passed as an argument. + require.Len(t, runner.lastRunArgs.Args, 1) + assert.Equal(t, scriptPath, runner.lastRunArgs.Args[0]) +} + +func TestPythonExecute_WithoutVenv(t *testing.T) { + dir := t.TempDir() + runner := &mockCommandRunner{} + e := newPythonExecutorInternal(runner, &mockPythonTools{}) + + execCtx := tools.ExecutionContext{BoundaryDir: dir} + scriptPath := filepath.Join(dir, "hook.py") + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + // With no venv, system Python should be used. + if runtime.GOOS == "windows" { + // Default mock returns nil for ToolInPath → "py". + assert.Equal(t, "py", runner.lastRunArgs.Cmd) + } else { + assert.Equal(t, "python3", runner.lastRunArgs.Cmd) + } +} + +func TestPythonExecute_EnvVarsPassthrough(t *testing.T) { + runner := &mockCommandRunner{} + envVars := []string{"FOO=bar", "BAZ=qux"} + e := newPythonExecutorInternal(runner, &mockPythonTools{}) + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + EnvVars: envVars, + } + scriptPath := filepath.Join(t.TempDir(), "hook.py") + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + assert.Equal(t, envVars, runner.lastRunArgs.Env) +} + +func TestPythonExecute_InteractiveMode(t *testing.T) { + runner := &mockCommandRunner{} + e := newPythonExecutorInternal(runner, &mockPythonTools{}) + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + Interactive: new(true), + } + scriptPath := filepath.Join(t.TempDir(), "hook.py") + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + assert.True(t, runner.lastRunArgs.Interactive) +} + +func TestPythonExecute_WorkingDirectory(t *testing.T) { + t.Run("ConfiguredCwd", func(t *testing.T) { + customCwd := filepath.Join(t.TempDir(), "custom") + require.NoError(t, os.MkdirAll(customCwd, 0o700)) + + runner := &mockCommandRunner{} + e := newPythonExecutorInternal(runner, &mockPythonTools{}) + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + Cwd: customCwd, + } + scriptPath := filepath.Join(t.TempDir(), "hook.py") + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + assert.Equal(t, customCwd, runner.lastRunArgs.Cwd) + }) + + t.Run("FallbackToScriptDir", func(t *testing.T) { + runner := &mockCommandRunner{} + e := newPythonExecutorInternal(runner, &mockPythonTools{}) + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + // Cwd intentionally empty + } + scriptDir := filepath.Join(t.TempDir(), "scripts") + scriptPath := filepath.Join(scriptDir, "hook.py") + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + assert.Equal(t, scriptDir, runner.lastRunArgs.Cwd) + }) +} + +// --------------------------------------------------------------------------- +// 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 718b6c7ce7b..050607f1b38 100644 --- a/cli/azd/pkg/tools/powershell/powershell.go +++ b/cli/azd/pkg/tools/powershell/powershell.go @@ -6,8 +6,8 @@ package powershell import ( "context" "fmt" + "os" "runtime" - "strings" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/exec" @@ -15,76 +15,133 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/tools" ) -// Creates a new PowershellScript command runner -func NewPowershellScript(commandRunner exec.CommandRunner, cwd string, envVars []string) tools.Script { - return &powershellScript{ +// NewExecutor creates a PowerShell HookExecutor. Takes only IoC-injectable deps. +func NewExecutor(commandRunner exec.CommandRunner) tools.HookExecutor { + return &powershellExecutor{ commandRunner: commandRunner, - cwd: cwd, - envVars: envVars, + shellCmd: "pwsh", // default, resolved in Prepare } } -type powershellScript struct { +type powershellExecutor struct { commandRunner exec.CommandRunner - cwd string - envVars []string + shellCmd string // resolved in Prepare: "pwsh" or "powershell" + tempFile string // temp script created from inline content + usingFallback bool // true when PS5 "powershell" is used instead of "pwsh" } -func (ps *powershellScript) checkPath(options tools.ExecOptions) error { - return ps.commandRunner.ToolInPath(strings.Split(options.UserPwsh, " ")[0]) -} +// 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. When the execution context carries +// inline script content, a temp .ps1 file is created. +func (p *powershellExecutor) Prepare( + _ 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 != "" { + 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" -// 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 { - - 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")), - } + path, err := tools.CreateInlineTempScript( + execCtx.HookName, ".ps1", content, + ) + if err != nil { + return err } + p.tempFile = path + } - 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 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" + return nil + } + + // On Windows, fall back to powershell (PS5). + if runtime.GOOS == "windows" { + if p.commandRunner.ToolInPath("powershell") == nil { + p.shellCmd = "powershell" + p.usingFallback = true + return nil + } + return &internal.ErrorWithSuggestion{ + Err: fmt.Errorf("neither pwsh nor powershell found in PATH"), + Suggestion: fmt.Sprintf( + "Make sure pwsh (PowerShell 7) or powershell (PowerShell 5) is installed. Visit %s", + output.WithLinkFormat( + "https://learn.microsoft.com/powershell/scripting/install/installing-powershell", + )), } } - runArgs := exec.NewRunArgs(options.UserPwsh, path). - WithCwd(ps.cwd). - WithEnv(ps.envVars). + // 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. 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). 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) } - 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")), - } - } + 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 +} - return result, err +// 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/powershell/powershell_test.go b/cli/azd/pkg/tools/powershell/powershell_test.go index 88b41e233cc..beb3d64bce3 100644 --- a/cli/azd/pkg/tools/powershell/powershell_test.go +++ b/cli/azd/pkg/tools/powershell/powershell_test.go @@ -7,78 +7,125 @@ import ( "context" "errors" "fmt" + "os" "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/osutil" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/stretchr/testify/require" ) -func Test_Powershell_Execute(t *testing.T) { - workingDir := "cwd" - scriptPath := "path/script.ps1" - env := []string{ - "a=apple", - "b=banana", - } +func Test_Powershell_Prepare(t *testing.T) { + emptyCtx := tools.ExecutionContext{} - t.Run("Success", func(t *testing.T) { + t.Run("PwshAvailable", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) - - // Mock ToolInPath to simulate pwsh being available mockContext.CommandRunner.MockToolInPath("pwsh", nil) - // #nosec G101 - userPwsh := "pwsh -NoProfile" - mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { - return strings.Contains(args.Cmd, userPwsh) - }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { - require.Equal(t, userPwsh, args.Cmd) - require.Equal(t, workingDir, args.Cwd) - require.Equal(t, scriptPath, args.Args[0]) - require.Equal(t, env, args.Env) + ps := NewExecutor(mockContext.CommandRunner) + err := ps.Prepare(*mockContext.Context, "script.ps1", emptyCtx) - return exec.NewRunResult(0, "", ""), nil - }) + require.NoError(t, err) + }) - powershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) - runResult, err := powershellScript.Execute( - *mockContext.Context, - scriptPath, - tools.ExecOptions{UserPwsh: userPwsh, Interactive: new(true)}, + t.Run("PwshNotAvailableFallbackWindows", func(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("pwsh fallback to powershell is only for Windows") + } + + mockContext := mocks.NewMockContext(context.Background()) + mockContext.CommandRunner.MockToolInPath( + "pwsh", fmt.Errorf("pwsh: command not found"), ) + mockContext.CommandRunner.MockToolInPath("powershell", nil) + + ps := NewExecutor(mockContext.CommandRunner) + err := ps.Prepare(*mockContext.Context, "script.ps1", emptyCtx) - require.NotNil(t, runResult) require.NoError(t, err) }) - t.Run("Success - alternative", func(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip("pwsh alternative is only for Windows") + t.Run("NoPowerShellInstalled", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + mockContext.CommandRunner.MockToolInPath( + "pwsh", errors.New("pwsh: command not found"), + ) + mockContext.CommandRunner.MockToolInPath( + "powershell", errors.New("powershell: command not found"), + ) + + 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 { + 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) - // #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) + 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) - return exec.NewRunResult(1, "not found", "not found"), nil - }) + // 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) { + 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 +133,15 @@ func Test_Powershell_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - // Mock ToolInPath to simulate pwsh being available - mockContext.CommandRunner.MockToolInPath("pwsh", fmt.Errorf("failed to find PowerShell executable")) + ps := NewExecutor(mockContext.CommandRunner) + execCtx := tools.ExecutionContext{Cwd: workingDir, EnvVars: env} + require.NoError(t, ps.Prepare(*mockContext.Context, scriptPath, execCtx)) - powershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) - runResult, err := powershellScript.Execute( + execCtx.Interactive = new(true) + runResult, err := ps.Execute( *mockContext.Context, scriptPath, - tools.ExecOptions{UserPwsh: userPwsh, Interactive: new(true)}, + execCtx, ) require.NotNil(t, runResult) @@ -102,8 +150,6 @@ func Test_Powershell_Execute(t *testing.T) { t.Run("Error", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) - - // Mock ToolInPath to simulate pwsh being available mockContext.CommandRunner.MockToolInPath("pwsh", nil) mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { @@ -112,47 +158,36 @@ func Test_Powershell_Execute(t *testing.T) { return exec.NewRunResult(1, "", "error message"), errors.New("error message") }) - powershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) - runResult, err := powershellScript.Execute( - *mockContext.Context, - scriptPath, - tools.ExecOptions{UserPwsh: "pwsh", Interactive: new(true)}, - ) - - 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")) + ps := NewExecutor(mockContext.CommandRunner) + execCtx := tools.ExecutionContext{Cwd: workingDir, EnvVars: env} + require.NoError(t, ps.Prepare(*mockContext.Context, scriptPath, execCtx)) - powershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) - _, err := powershellScript.Execute( + execCtx.Interactive = new(true) + runResult, err := ps.Execute( *mockContext.Context, scriptPath, - tools.ExecOptions{UserPwsh: "pwsh", Interactive: new(true)}, + execCtx, ) + require.Equal(t, 1, runResult.ExitCode) require.Error(t, err) }) tests := []struct { name string - value tools.ExecOptions + value tools.ExecutionContext }{ - {name: "Interactive", value: tools.ExecOptions{UserPwsh: "pwsh", Interactive: new(true)}}, - {name: "NonInteractive", value: tools.ExecOptions{UserPwsh: "pwsh", Interactive: new(false)}}, + {name: "Interactive", value: tools.ExecutionContext{ + Cwd: workingDir, EnvVars: env, Interactive: new(true), + }}, + {name: "NonInteractive", value: tools.ExecutionContext{ + Cwd: workingDir, EnvVars: env, Interactive: new(false), + }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) - - // Mock ToolInPath to simulate pwsh being available mockContext.CommandRunner.MockToolInPath("pwsh", nil) mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { @@ -162,8 +197,11 @@ func Test_Powershell_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - powershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) - runResult, err := powershellScript.Execute(*mockContext.Context, scriptPath, test.value) + ps := NewExecutor(mockContext.CommandRunner) + execCtx := tools.ExecutionContext{Cwd: workingDir, EnvVars: env} + require.NoError(t, ps.Prepare(*mockContext.Context, scriptPath, execCtx)) + + runResult, err := ps.Execute(*mockContext.Context, scriptPath, test.value) require.NotNil(t, runResult) require.NoError(t, err) diff --git a/cli/azd/pkg/tools/script.go b/cli/azd/pkg/tools/script.go index 2526b78bdf3..0740aa516c4 100644 --- a/cli/azd/pkg/tools/script.go +++ b/cli/azd/pkg/tools/script.go @@ -5,19 +5,107 @@ 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" ) -// 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. + // Executors may walk upward from the script to BoundaryDir + // to discover dependency files (requirements.txt, package.json). + BoundaryDir string + + // Interactive controls whether stdin is attached. Interactive *bool - StdOut io.Writer - UserPwsh string + + // StdOut overrides the default stdout for the process. + StdOut io.Writer + + // 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 } -// Utility to easily execute a bash script across platforms -type Script interface { - Execute(ctx context.Context, scriptPath string, options ExecOptions) (exec.RunResult, error) +// HookExecutor is the unified interface for all hook execution. +// Every executor follows a 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, 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 +} + +// 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/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/cli/azd/test/mocks/mocktools/hook_executors.go b/cli/azd/test/mocks/mocktools/hook_executors.go new file mode 100644 index 00000000000..381515bb7ab --- /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.HookKindBash), bash.NewExecutor, + ) + mockCtx.Container.MustRegisterNamedTransient( + string(language.HookKindPowerShell), powershell.NewExecutor, + ) + mockCtx.Container.MustRegisterSingleton(python.NewCli) + mockCtx.Container.MustRegisterNamedTransient( + string(language.HookKindPython), language.NewPythonExecutor, + ) +} diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index 62787b39245..8b60cc53ae1 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -827,6 +827,24 @@ ], "default": "sh" }, + "kind": { + "type": "string", + "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", + "js", + "ts", + "python", + "dotnet" + ] + }, + "dir": { + "type": "string", + "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", "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, + "kind": false, + "dir": false, "interactive": false, "continueOnError": false, "secrets": false @@ -918,6 +938,16 @@ "required": [ "shell" ] + }, + { + "required": [ + "kind" + ] + }, + { + "required": [ + "dir" + ] } ] }, diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index 31f2de6c77b..90ffed2f2b1 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -787,6 +787,24 @@ ], "default": "sh" }, + "kind": { + "type": "string", + "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", + "js", + "ts", + "python", + "dotnet" + ] + }, + "dir": { + "type": "string", + "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", "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, + "kind": false, + "dir": false, "interactive": false, "continueOnError": false, "secrets": false @@ -878,6 +898,16 @@ "required": [ "shell" ] + }, + { + "required": [ + "kind" + ] + }, + { + "required": [ + "dir" + ] } ] },