diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 386ee69820e..ac590e59491 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -383,6 +383,12 @@ overrides: - unvalidated - venvs - workdir + - filename: pkg/tools/language/python_executor.go + words: + - venvs + - filename: extensions/azure.ai.agents/internal/cmd/init_locations.go + words: + - swedencentral - filename: docs/code-coverage-guide.md words: - covdata diff --git a/cli/azd/pkg/ext/hooks_config_test.go b/cli/azd/pkg/ext/hooks_config_test.go index 69829b0d075..cc32b41e18d 100644 --- a/cli/azd/pkg/ext/hooks_config_test.go +++ b/cli/azd/pkg/ext/hooks_config_test.go @@ -246,6 +246,319 @@ predeploy: "script4.sh", hooks["predeploy"][0].Run, ) }) + + t.Run("hook with config block", func(t *testing.T) { + const doc = ` +postprovision: + run: ./hooks/seed-database.cs + kind: dotnet + config: + configuration: Release + framework: net10.0 +` + + var hooks HooksConfig + err := yaml.Unmarshal([]byte(doc), &hooks) + require.NoError(t, err) + + require.Len(t, hooks["postprovision"], 1) + hook := hooks["postprovision"][0] + assert.Equal(t, "./hooks/seed-database.cs", hook.Run) + assert.Equal(t, + language.HookKindDotNet, hook.Kind, + ) + require.NotNil(t, hook.Config) + assert.Equal(t, "Release", hook.Config["configuration"]) + assert.Equal(t, "net10.0", hook.Config["framework"]) + }) + + t.Run("multiple hooks with different configs", + func(t *testing.T) { + const doc = ` +postprovision: + - run: ./hooks/seed-database.cs + kind: dotnet + config: + configuration: Release + - run: ./hooks/setup.py + kind: python + config: + virtualEnvName: .venv +` + + var hooks HooksConfig + err := yaml.Unmarshal([]byte(doc), &hooks) + require.NoError(t, err) + + require.Len(t, hooks["postprovision"], 2) + + dotnetHook := hooks["postprovision"][0] + assert.Equal(t, + "./hooks/seed-database.cs", dotnetHook.Run, + ) + require.NotNil(t, dotnetHook.Config) + assert.Equal(t, + "Release", + dotnetHook.Config["configuration"], + ) + + pythonHook := hooks["postprovision"][1] + assert.Equal(t, + "./hooks/setup.py", pythonHook.Run, + ) + require.NotNil(t, pythonHook.Config) + assert.Equal(t, + ".venv", + pythonHook.Config["virtualEnvName"], + ) + }) + + t.Run("nested config maps", func(t *testing.T) { + const doc = ` +postprovision: + run: ./hooks/setup.py + kind: python + config: + database: + host: localhost + port: 5432 + logging: + level: debug +` + + var hooks HooksConfig + err := yaml.Unmarshal([]byte(doc), &hooks) + require.NoError(t, err) + + require.Len(t, hooks["postprovision"], 1) + hook := hooks["postprovision"][0] + require.NotNil(t, hook.Config) + + db, ok := hook.Config["database"].(map[string]any) + require.True(t, ok, + "database should be map[string]any", + ) + assert.Equal(t, "localhost", db["host"]) + assert.Equal(t, 5432, db["port"]) + + logging, ok := + hook.Config["logging"].(map[string]any) + require.True(t, ok, + "logging should be map[string]any", + ) + assert.Equal(t, "debug", logging["level"]) + }) + + t.Run("config with list values", func(t *testing.T) { + const doc = ` +postprovision: + run: ./hooks/setup.sh + config: + paths: + - ./src + - ./lib + flags: + - --verbose + - --dry-run +` + + var hooks HooksConfig + err := yaml.Unmarshal([]byte(doc), &hooks) + require.NoError(t, err) + + require.Len(t, hooks["postprovision"], 1) + hook := hooks["postprovision"][0] + require.NotNil(t, hook.Config) + + paths, ok := hook.Config["paths"].([]any) + require.True(t, ok, + "paths should be []any", + ) + require.Len(t, paths, 2) + assert.Equal(t, "./src", paths[0]) + assert.Equal(t, "./lib", paths[1]) + + flags, ok := hook.Config["flags"].([]any) + require.True(t, ok, + "flags should be []any", + ) + require.Len(t, flags, 2) + assert.Equal(t, "--verbose", flags[0]) + assert.Equal(t, "--dry-run", flags[1]) + }) + + t.Run("platform override hooks with config", + func(t *testing.T) { + const doc = ` +postprovision: + run: ./hooks/setup.py + kind: python + config: + virtualEnvName: .venv + windows: + run: .\hooks\setup.py + kind: python + config: + virtualEnvName: .win-venv + posix: + run: ./hooks/setup.py + kind: python + config: + virtualEnvName: .posix-venv +` + + var hooks HooksConfig + err := yaml.Unmarshal([]byte(doc), &hooks) + require.NoError(t, err) + + require.Len(t, hooks["postprovision"], 1) + hook := hooks["postprovision"][0] + + require.NotNil(t, hook.Config) + assert.Equal(t, + ".venv", + hook.Config["virtualEnvName"], + ) + + require.NotNil(t, hook.Windows) + require.NotNil(t, hook.Windows.Config) + assert.Equal(t, + ".win-venv", + hook.Windows.Config["virtualEnvName"], + ) + + require.NotNil(t, hook.Posix) + require.NotNil(t, hook.Posix.Config) + assert.Equal(t, + ".posix-venv", + hook.Posix.Config["virtualEnvName"], + ) + }) + + t.Run("config type preservation", + func(t *testing.T) { + const doc = ` +postprovision: + run: ./hooks/setup.sh + config: + retries: 3 + verbose: true + timeout: 30.5 + name: my-hook +` + + var hooks HooksConfig + err := yaml.Unmarshal([]byte(doc), &hooks) + require.NoError(t, err) + + require.Len(t, hooks["postprovision"], 1) + hook := hooks["postprovision"][0] + require.NotNil(t, hook.Config) + + assert.IsType(t, 0, hook.Config["retries"]) + assert.Equal(t, 3, hook.Config["retries"]) + + assert.IsType(t, + true, hook.Config["verbose"], + ) + assert.Equal(t, + true, hook.Config["verbose"], + ) + + assert.IsType(t, + 0.0, hook.Config["timeout"], + ) + assert.InDelta(t, + 30.5, hook.Config["timeout"], 0.001, + ) + + assert.IsType(t, "", hook.Config["name"]) + assert.Equal(t, + "my-hook", hook.Config["name"], + ) + + // Roundtrip preserves types. + data, err := yaml.Marshal(hooks) + require.NoError(t, err) + + var got HooksConfig + err = yaml.Unmarshal(data, &got) + require.NoError(t, err) + + rtHook := got["postprovision"][0] + require.NotNil(t, rtHook.Config) + assert.Equal(t, hook.Config, rtHook.Config) + }) + + t.Run("empty config block", func(t *testing.T) { + const doc = ` +postprovision: + run: ./hooks/setup.sh + config: {} +` + + var hooks HooksConfig + err := yaml.Unmarshal([]byte(doc), &hooks) + require.NoError(t, err) + + require.Len(t, hooks["postprovision"], 1) + hook := hooks["postprovision"][0] + + require.NotNil(t, hook.Config) + assert.Empty(t, hook.Config) + + // Marshal omits empty config via omitempty tag. + data, err := yaml.Marshal(hooks) + require.NoError(t, err) + assert.NotContains(t, string(data), "config") + }) + + t.Run("config in list-format hooks", + func(t *testing.T) { + const doc = ` +postprovision: + - run: ./hooks/first.py + kind: python + config: + virtualEnvName: .venv + - run: ./hooks/second.ts + kind: ts + config: + packageManager: pnpm +` + + var hooks HooksConfig + err := yaml.Unmarshal([]byte(doc), &hooks) + require.NoError(t, err) + + require.Len(t, hooks["postprovision"], 2) + + first := hooks["postprovision"][0] + assert.Equal(t, + "./hooks/first.py", first.Run, + ) + assert.Equal(t, + language.HookKindPython, first.Kind, + ) + require.NotNil(t, first.Config) + assert.Equal(t, + ".venv", + first.Config["virtualEnvName"], + ) + + second := hooks["postprovision"][1] + assert.Equal(t, + "./hooks/second.ts", second.Run, + ) + assert.Equal(t, + language.HookKindTypeScript, second.Kind, + ) + require.NotNil(t, second.Config) + assert.Equal(t, + "pnpm", + second.Config["packageManager"], + ) + }) } func TestHooksConfig_MarshalYAML(t *testing.T) { @@ -364,6 +677,42 @@ preprovision: got["preprovision"][1].Run, ) }) + + t.Run("roundtrip with config block", func(t *testing.T) { + hooks := HooksConfig{ + "postprovision": { + { + Run: "./hooks/seed.cs", + Kind: language.HookKindDotNet, + Config: map[string]any{ + "configuration": "Release", + "framework": "net10.0", + }, + }, + }, + } + + data, err := yaml.Marshal(hooks) + require.NoError(t, err) + + var got HooksConfig + err = yaml.Unmarshal(data, &got) + require.NoError(t, err) + + require.Len(t, got["postprovision"], 1) + hook := got["postprovision"][0] + assert.Equal(t, "./hooks/seed.cs", hook.Run) + assert.Equal(t, + language.HookKindDotNet, hook.Kind, + ) + require.NotNil(t, hook.Config) + assert.Equal(t, + "Release", hook.Config["configuration"], + ) + assert.Equal(t, + "net10.0", hook.Config["framework"], + ) + }) } func TestHooksConfig_RoundTrip(t *testing.T) { diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index 9e8c1066b41..c100c89e32d 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -178,6 +178,7 @@ func (h *HooksRunner) execHook( BoundaryDir: boundaryDir, InlineScript: hookConfig.inlineScript, HookName: hookConfig.Name, + Config: hookConfig.Config, } // Merge caller-provided overrides (e.g. forced interactive from 'azd hooks run'). diff --git a/cli/azd/pkg/ext/hooks_runner_test.go b/cli/azd/pkg/ext/hooks_runner_test.go index ff3625ca3c3..510c9c10144 100644 --- a/cli/azd/pkg/ext/hooks_runner_test.go +++ b/cli/azd/pkg/ext/hooks_runner_test.go @@ -14,6 +14,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "github.com/azure/azure-dev/cli/azd/pkg/tools" "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" @@ -1049,3 +1050,193 @@ func Test_ExecHook_DirRunResolution(t *testing.T) { ) }) } + +// configCaptureExecutor is a test-only [tools.HookExecutor] that +// captures the [tools.ExecutionContext] passed to Execute so tests +// can assert on it. +type configCaptureExecutor struct { + onExecute func(tools.ExecutionContext) +} + +func (e *configCaptureExecutor) Prepare( + _ context.Context, _ string, _ tools.ExecutionContext, +) error { + return nil +} + +func (e *configCaptureExecutor) Execute( + _ context.Context, _ string, execCtx tools.ExecutionContext, +) (exec.RunResult, error) { + if e.onExecute != nil { + e.onExecute(execCtx) + } + return exec.NewRunResult(0, "", ""), nil +} + +func (e *configCaptureExecutor) Cleanup( + _ context.Context, +) error { + return nil +} + +// Test_ExecHook_ConfigThreading verifies that HookConfig.Config is +// threaded through to ExecutionContext.Config when executing a hook. +func Test_ExecHook_ConfigThreading(t *testing.T) { + t.Run("ConfigPassedToExecutor", func(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + // Create a script file so validate() resolves it. + require.NoError(t, os.MkdirAll( + filepath.Join(cwd, "scripts"), + osutil.PermissionDirectory, + )) + require.NoError(t, os.WriteFile( + filepath.Join(cwd, "scripts", "hook.sh"), + nil, osutil.PermissionExecutableFile, + )) + + expectedConfig := map[string]any{ + "key1": "value1", + "number": 42, + "nested": map[string]any{"a": "b"}, + } + + hooksMap := map[string][]*HookConfig{ + "predeploy": { + { + Name: "predeploy", + Shell: string(language.HookKindBash), + Run: "scripts/hook.sh", + Config: expectedConfig, + }, + }, + } + + envManager := &mockenv.MockEnvManager{} + envManager.On( + "Reload", mock.Anything, env, + ).Return(nil) + + var capturedCtx tools.ExecutionContext + mockContext := mocks.NewMockContext( + context.Background(), + ) + + // Register a capture executor instead of the real + // bash executor so we can inspect ExecutionContext. + mockContext.Container.MustRegisterNamedTransient( + string(language.HookKindBash), + func() tools.HookExecutor { + return &configCaptureExecutor{ + onExecute: func( + ctx tools.ExecutionContext, + ) { + capturedCtx = ctx + }, + } + }, + ) + + hooksManager := NewHooksManager( + HooksManagerOptions{Cwd: 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.Equal(t, expectedConfig, capturedCtx.Config) + require.Equal(t, "predeploy", capturedCtx.HookName) + }) + + t.Run("NilConfigWhenNotSet", func(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + require.NoError(t, os.MkdirAll( + filepath.Join(cwd, "scripts"), + osutil.PermissionDirectory, + )) + require.NoError(t, os.WriteFile( + filepath.Join(cwd, "scripts", "hook.sh"), + nil, osutil.PermissionExecutableFile, + )) + + hooksMap := map[string][]*HookConfig{ + "predeploy": { + { + Name: "predeploy", + Shell: string(language.HookKindBash), + Run: "scripts/hook.sh", + // No Config set. + }, + }, + } + + envManager := &mockenv.MockEnvManager{} + envManager.On( + "Reload", mock.Anything, env, + ).Return(nil) + + var capturedCtx tools.ExecutionContext + mockContext := mocks.NewMockContext( + context.Background(), + ) + + mockContext.Container.MustRegisterNamedTransient( + string(language.HookKindBash), + func() tools.HookExecutor { + return &configCaptureExecutor{ + onExecute: func( + ctx tools.ExecutionContext, + ) { + capturedCtx = ctx + }, + } + }, + ) + + hooksManager := NewHooksManager( + HooksManagerOptions{Cwd: 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.Nil(t, capturedCtx.Config) + }) +} diff --git a/cli/azd/pkg/ext/models.go b/cli/azd/pkg/ext/models.go index c067020f156..ad6ba9e58aa 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -6,6 +6,7 @@ package ext import ( "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "fmt" "maps" @@ -132,6 +133,10 @@ type HookConfig struct { // Environment variables in this list are added to the hook script and if the value is a akvs:// reference // it will be resolved to the secret value Secrets map[string]string `yaml:"secrets,omitempty"` + // Config is an optional property bag of executor-specific settings, + // discriminated by Kind. Each executor may unmarshal this into a + // strongly-typed struct for its own configuration needs. + Config map[string]any `yaml:"config,omitempty"` } // validate normalizes and validates the hook configuration. It resolves @@ -585,6 +590,20 @@ func appendHookConfigSignature(builder *strings.Builder, hookConfig *HookConfig) builder.WriteByte('\x00') } + // Config is a map[string]any — use JSON for deterministic key ordering. + if len(hookConfig.Config) > 0 { + configJSON, err := json.Marshal(hookConfig.Config) + // Config values originate from azure.yaml, which produces + // JSON-compatible types (strings, numbers, bools, maps, slices). + // Marshal errors are theoretically possible but not in practice, + // so we skip the config contribution rather than failing the + // signature calculation. + if err == nil { + builder.Write(configJSON) + } + builder.WriteByte('\x00') + } + appendHookConfigSignature(builder, hookConfig.Windows) appendHookConfigSignature(builder, hookConfig.Posix) } diff --git a/cli/azd/pkg/ext/models_test.go b/cli/azd/pkg/ext/models_test.go index 378e47a52cb..95258e2ce58 100644 --- a/cli/azd/pkg/ext/models_test.go +++ b/cli/azd/pkg/ext/models_test.go @@ -480,7 +480,7 @@ func TestHookConfig_ValidateDirRunResolution(t *testing.T) { Run: "main.py", Dir: filepath.Join("hooks", "preprovision"), }, - // Put file in dir but also at root — the + // 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{ @@ -753,7 +753,7 @@ func TestHookConfig_ResolvedPaths(t *testing.T) { func TestHookConfig_ValidatePathTraversal(t *testing.T) { cwd := t.TempDir() - // File-based hook with Dir escaping project root — must fail. + // File-based hook with Dir escaping project root ΓÇö must fail. escapeDir := filepath.Join(cwd, "..", "..", "escape") require.NoError(t, os.MkdirAll(escapeDir, 0o755)) scriptPath := filepath.Join(escapeDir, "evil.sh") @@ -893,7 +893,7 @@ func TestHookConfig_ServiceHookEscapesProjectRoot( ) require.NoError(t, os.MkdirAll(serviceCwd, 0o755)) - // Script file outside the project root — the + // Script file outside the project root ΓÇö the // boundary check must reject this. outsideDir := t.TempDir() scriptPath := filepath.Join(outsideDir, "evil.sh") @@ -1326,3 +1326,170 @@ func TestHookConfig_DirRunKindInference(t *testing.T) { }) } } + +func TestHookConfig_ConfigField(t *testing.T) { + tests := []struct { + name string + yamlInput string + expectedConfig map[string]any + }{ + { + name: "StringValues", + yamlInput: "run: hooks/seed.py\nkind: python\nconfig:\n" + + " framework: net10.0\n configuration: Release\n", + expectedConfig: map[string]any{ + "framework": "net10.0", + "configuration": "Release", + }, + }, + { + name: "NumericValue", + yamlInput: "run: hooks/seed.py\nkind: python\nconfig:\n" + + " retries: 3\n timeout: 30.5\n", + expectedConfig: map[string]any{ + "retries": 3, + "timeout": 30.5, + }, + }, + { + name: "BooleanValue", + yamlInput: "run: hooks/seed.py\nkind: python\nconfig:\n" + + " verbose: true\n", + expectedConfig: map[string]any{ + "verbose": true, + }, + }, + { + name: "NestedMap", + yamlInput: "run: hooks/seed.py\nkind: python\nconfig:\n" + + " database:\n host: localhost\n port: 5432\n", + expectedConfig: map[string]any{ + "database": map[string]any{ + "host": "localhost", + "port": 5432, + }, + }, + }, + { + name: "ListValue", + yamlInput: "run: hooks/seed.py\nkind: python\nconfig:\n" + + " args:\n - --verbose\n - --dry-run\n", + expectedConfig: map[string]any{ + "args": []any{"--verbose", "--dry-run"}, + }, + }, + { + name: "NoConfig", + yamlInput: "run: hooks/seed.py\nkind: python\n", + expectedConfig: nil, + }, + } + + 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.expectedConfig, config.Config) + }) + } +} + +func TestHookConfig_ConfigRoundTrip(t *testing.T) { + original := HookConfig{ + Run: "hooks/deploy.py", + Kind: language.HookKindPython, + Config: map[string]any{ + "framework": "net10.0", + "configuration": "Release", + "nested": map[string]any{"key": "val"}, + }, + } + + 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.Config, decoded.Config) + require.Equal(t, original.Kind, decoded.Kind) + require.Equal(t, original.Run, decoded.Run) +} + +func TestHookConfig_ConfigOmittedWhenEmpty(t *testing.T) { + t.Run("nil config omitted", func(t *testing.T) { + config := HookConfig{ + Run: "hooks/deploy.py", + Kind: language.HookKindPython, + } + + data, err := yaml.Marshal(&config) + require.NoError(t, err) + + yamlStr := string(data) + require.NotContains(t, yamlStr, "config") + }) + + t.Run("empty map omitted", func(t *testing.T) { + config := HookConfig{ + Run: "hooks/deploy.py", + Kind: language.HookKindPython, + Config: map[string]any{}, + } + + data, err := yaml.Marshal(&config) + require.NoError(t, err) + + yamlStr := string(data) + require.NotContains(t, yamlStr, "config") + }) +} + +func TestHooksConfigSignature_IncludesConfig(t *testing.T) { + base := map[string][]*HookConfig{ + "predeploy": { + { + Run: "hooks/deploy.py", + Kind: language.HookKindPython, + }, + }, + } + sigWithout := HooksConfigSignature(base) + require.NotEmpty(t, sigWithout) + + withConfig := map[string][]*HookConfig{ + "predeploy": { + { + Run: "hooks/deploy.py", + Kind: language.HookKindPython, + Config: map[string]any{ + "framework": "net10.0", + }, + }, + }, + } + sigWith := HooksConfigSignature(withConfig) + require.NotEmpty(t, sigWith) + require.NotEqual(t, sigWithout, sigWith, + "signature should change when Config is added", + ) + + // Different Config values should produce different signatures. + withDifferentConfig := map[string][]*HookConfig{ + "predeploy": { + { + Run: "hooks/deploy.py", + Kind: language.HookKindPython, + Config: map[string]any{ + "framework": "net9.0", + }, + }, + }, + } + sigDiff := HooksConfigSignature(withDifferentConfig) + require.NotEqual(t, sigWith, sigDiff, + "signature should differ for different Config values", + ) +} diff --git a/cli/azd/pkg/tools/hook_config.go b/cli/azd/pkg/tools/hook_config.go new file mode 100644 index 00000000000..e24889dab6e --- /dev/null +++ b/cli/azd/pkg/tools/hook_config.go @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package tools + +import ( + "encoding/json" + "fmt" +) + +// UnmarshalHookConfig unmarshals the raw config map into a typed struct. +// Returns a zero-value T if config is nil or empty. +// Uses JSON re-marshal for deterministic conversion with proper type checking. +func UnmarshalHookConfig[T any](config map[string]any) (T, error) { + var result T + + if len(config) == 0 { + return result, nil + } + + data, err := json.Marshal(config) + if err != nil { + return result, fmt.Errorf("marshalling hook config to JSON: %w", err) + } + + if err := json.Unmarshal(data, &result); err != nil { + return result, fmt.Errorf("unmarshalling hook config: %w", err) + } + + return result, nil +} diff --git a/cli/azd/pkg/tools/hook_config_test.go b/cli/azd/pkg/tools/hook_config_test.go new file mode 100644 index 00000000000..2ceb31d405e --- /dev/null +++ b/cli/azd/pkg/tools/hook_config_test.go @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package tools + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +type testNodeConfig struct { + PackageManager string `json:"packageManager"` +} + +type testPythonConfig struct { + VirtualEnvName string `json:"virtualEnvName"` +} + +type testDotnetConfig struct { + Configuration string `json:"configuration"` + Framework string `json:"framework"` +} + +type testNestedConfig struct { + Name string `json:"name"` + Count int `json:"count"` + Verbose bool `json:"verbose"` +} + +func TestUnmarshalHookConfig(t *testing.T) { + tests := []struct { + name string + run func(t *testing.T) + }{ + { + name: "NilConfig", + run: func(t *testing.T) { + result, err := UnmarshalHookConfig[testNodeConfig](nil) + require.NoError(t, err) + require.Equal(t, testNodeConfig{}, result) + }, + }, + { + name: "EmptyConfig", + run: func(t *testing.T) { + result, err := UnmarshalHookConfig[testNodeConfig](map[string]any{}) + require.NoError(t, err) + require.Equal(t, testNodeConfig{}, result) + }, + }, + { + name: "ValidStringField", + run: func(t *testing.T) { + config := map[string]any{ + "packageManager": "pnpm", + } + result, err := UnmarshalHookConfig[testNodeConfig](config) + require.NoError(t, err) + require.Equal(t, "pnpm", result.PackageManager) + }, + }, + { + name: "ValidPythonConfig", + run: func(t *testing.T) { + config := map[string]any{ + "virtualEnvName": ".myenv", + } + result, err := UnmarshalHookConfig[testPythonConfig](config) + require.NoError(t, err) + require.Equal(t, ".myenv", result.VirtualEnvName) + }, + }, + { + name: "ValidDotnetConfigMultipleFields", + run: func(t *testing.T) { + config := map[string]any{ + "configuration": "Release", + "framework": "net8.0", + } + result, err := UnmarshalHookConfig[testDotnetConfig](config) + require.NoError(t, err) + require.Equal(t, "Release", result.Configuration) + require.Equal(t, "net8.0", result.Framework) + }, + }, + { + name: "ValidNestedTypes", + run: func(t *testing.T) { + config := map[string]any{ + "name": "test-hook", + "count": 42, + "verbose": true, + } + result, err := UnmarshalHookConfig[testNestedConfig](config) + require.NoError(t, err) + require.Equal(t, "test-hook", result.Name) + require.Equal(t, 42, result.Count) + require.True(t, result.Verbose) + }, + }, + { + name: "TypeMismatchReturnsError", + run: func(t *testing.T) { + config := map[string]any{ + "packageManager": 123, + } + // JSON numbers unmarshal into strings as an error + _, err := UnmarshalHookConfig[testNodeConfig](config) + require.Error(t, err) + require.Contains(t, err.Error(), "unmarshalling hook config") + }, + }, + { + name: "UnknownKeysIgnored", + run: func(t *testing.T) { + config := map[string]any{ + "packageManager": "npm", + "unknownField": "some-value", + "anotherExtra": 42, + } + result, err := UnmarshalHookConfig[testNodeConfig](config) + require.NoError(t, err) + require.Equal(t, "npm", result.PackageManager) + }, + }, + { + name: "PartialConfigUsesZeroDefaults", + run: func(t *testing.T) { + config := map[string]any{ + "configuration": "Debug", + } + result, err := UnmarshalHookConfig[testDotnetConfig](config) + require.NoError(t, err) + require.Equal(t, "Debug", result.Configuration) + require.Empty(t, result.Framework) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, tt.run) + } +} diff --git a/cli/azd/pkg/tools/language/dotnet_executor.go b/cli/azd/pkg/tools/language/dotnet_executor.go index cec5cc48379..809a9ef9ef7 100644 --- a/cli/azd/pkg/tools/language/dotnet_executor.go +++ b/cli/azd/pkg/tools/language/dotnet_executor.go @@ -35,6 +35,20 @@ var minSingleFileVersion = semver.Version{ Major: 10, Minor: 0, Patch: 0, } +// dotnetHookConfig holds the typed configuration that users can +// specify in azure.yaml under a .NET hook's config section. +type dotnetHookConfig struct { + // Configuration is the MSBuild configuration (Debug, Release, + // or a custom name). Passed as `-c` to dotnet build and + // dotnet run. Empty means use the SDK default (Debug). + Configuration string `json:"configuration"` + + // Framework is the target framework moniker (e.g. net8.0, + // net10.0). Passed as `--framework` to dotnet run so the + // correct TFM is selected in multi-target projects. + Framework string `json:"framework"` +} + // dotnetExecutor implements [tools.HookExecutor] for .NET (C#) // scripts. It supports two execution modes: // - Project mode: when a .csproj/.fsproj/.vbproj is discovered @@ -48,6 +62,9 @@ type dotnetExecutor struct { // projectPath is set by Prepare when a .NET project file is // discovered. Empty means single-file mode. projectPath string + + // config holds parsed hook configuration from azure.yaml. + config dotnetHookConfig } // NewDotNetExecutor creates a .NET HookExecutor. @@ -110,8 +127,18 @@ func (e *dotnetExecutor) Prepare( ) } - // 3a. Project mode: restore and build. + // 3. Project mode: parse config, restore, and build. if projCtx != nil { + cfg, err := tools.UnmarshalHookConfig[dotnetHookConfig]( + execCtx.Config, + ) + if err != nil { + return fmt.Errorf( + "parsing .NET hook config: %w", err, + ) + } + e.config = cfg + if err := e.dotnetCli.Restore( ctx, projCtx.DependencyFile, execCtx.EnvVars, ); err != nil { @@ -122,7 +149,8 @@ func (e *dotnetExecutor) Prepare( } if err := e.dotnetCli.Build( - ctx, projCtx.DependencyFile, "", "", + ctx, projCtx.DependencyFile, + e.config.Configuration, "", execCtx.EnvVars, ); err != nil { return fmt.Errorf( @@ -135,7 +163,7 @@ func (e *dotnetExecutor) Prepare( return nil } - // 3b. Single-file mode: validate SDK version >= 10. + // 4. Single-file mode: validate SDK version >= 10. sdkVer, err := e.dotnetCli.SdkVersion(ctx) if err != nil { return fmt.Errorf( @@ -188,11 +216,22 @@ func (e *dotnetExecutor) Execute( if e.projectPath != "" { // Project mode — skip restore/build since Prepare // already ran them. - runArgs = exec.NewRunArgs( - "dotnet", "run", + args := []string{ + "run", "--project", e.projectPath, "--no-build", - ) + } + + if e.config.Configuration != "" { + args = append(args, "-c", e.config.Configuration) + } + if e.config.Framework != "" { + args = append( + args, "--framework", e.config.Framework, + ) + } + + runArgs = exec.NewRunArgs("dotnet", args...) } else { // Single-file mode. runArgs = exec.NewRunArgs( diff --git a/cli/azd/pkg/tools/language/dotnet_executor_test.go b/cli/azd/pkg/tools/language/dotnet_executor_test.go index 89fb865df21..a444e645a28 100644 --- a/cli/azd/pkg/tools/language/dotnet_executor_test.go +++ b/cli/azd/pkg/tools/language/dotnet_executor_test.go @@ -29,10 +29,11 @@ type mockDotNetTools struct { restoreErr error buildErr error - restoreCalled bool - restoreProject string - buildCalled bool - buildProject string + restoreCalled bool + restoreProject string + buildCalled bool + buildProject string + buildConfiguration string } func (m *mockDotNetTools) CheckInstalled( @@ -59,11 +60,12 @@ func (m *mockDotNetTools) Restore( func (m *mockDotNetTools) Build( _ context.Context, - project string, _ string, + project string, configuration string, _ string, _ []string, ) error { m.buildCalled = true m.buildProject = project + m.buildConfiguration = configuration return m.buildErr } @@ -596,3 +598,159 @@ func TestDotNetExecutor_TableDriven(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// Hook config tests — configuration and framework flags +// --------------------------------------------------------------------------- + +func TestDotNetExecutor_HookConfig(t *testing.T) { + tests := []struct { + name string + config map[string]any + wantBuildCfg string + wantRunHas []string // substrings expected in run args + wantRunMissing []string // substrings that must NOT appear + }{ + { + name: "ConfigurationRelease", + config: map[string]any{"configuration": "Release"}, + wantBuildCfg: "Release", + wantRunHas: []string{"-c", "Release"}, + }, + { + name: "ConfigurationDebug", + config: map[string]any{"configuration": "Debug"}, + wantBuildCfg: "Debug", + wantRunHas: []string{"-c", "Debug"}, + }, + { + name: "FrameworkOnly", + config: map[string]any{"framework": "net10.0"}, + wantBuildCfg: "", + wantRunHas: []string{"--framework", "net10.0"}, + wantRunMissing: []string{"-c"}, + }, + { + name: "BothConfigAndFramework", + config: map[string]any{ + "configuration": "Release", + "framework": "net8.0", + }, + wantBuildCfg: "Release", + wantRunHas: []string{ + "-c", "Release", + "--framework", "net8.0", + }, + }, + { + name: "NeitherSet", + config: nil, + wantBuildCfg: "", + wantRunMissing: []string{"-c", "--framework"}, + }, + { + name: "EmptyConfigMap", + config: map[string]any{}, + wantBuildCfg: "", + wantRunMissing: []string{"-c", "--framework"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "proj") + require.NoError(t, + os.MkdirAll(projectDir, 0o700), + ) + writeFile(t, + filepath.Join(projectDir, "App.csproj"), + "", + ) + + cli := &mockDotNetTools{} + runner := &mockCommandRunner{} + e := newDotNetExecutorInternal(runner, cli) + + execCtx := tools.ExecutionContext{ + BoundaryDir: root, + Cwd: projectDir, + Config: tt.config, + } + scriptPath := filepath.Join( + projectDir, "hook.cs", + ) + + require.NoError(t, + e.Prepare(t.Context(), scriptPath, execCtx), + ) + + // Verify Build received the configuration. + assert.Equal(t, tt.wantBuildCfg, + cli.buildConfiguration, + "Build configuration mismatch", + ) + + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + // Verify run args contain expected flags. + runArgs := runner.lastRunArgs.Args + for _, want := range tt.wantRunHas { + assert.Contains(t, runArgs, want, + "run args should contain %q", want, + ) + } + + // Verify run args do NOT contain excluded flags. + for _, absent := range tt.wantRunMissing { + assert.NotContains(t, runArgs, absent, + "run args should not contain %q", + absent, + ) + } + + // Always verify project-mode invariants. + assert.Contains(t, runArgs, "--project") + assert.Contains(t, runArgs, "--no-build") + }) + } +} + +func TestDotNetExecutor_SingleFileIgnoresConfig(t *testing.T) { + dir := t.TempDir() + runner := &mockCommandRunner{} + cli := &mockDotNetTools{ + sdkVersionResult: semver.Version{ + Major: 10, Minor: 0, Patch: 0, + }, + } + e := newDotNetExecutorInternal(runner, cli) + + scriptPath := filepath.Join(dir, "hook.cs") + execCtx := tools.ExecutionContext{ + BoundaryDir: dir, + Config: map[string]any{ + "configuration": "Release", + "framework": "net10.0", + }, + } + + require.NoError(t, + e.Prepare(t.Context(), scriptPath, execCtx), + ) + + _, err := e.Execute( + t.Context(), scriptPath, execCtx, + ) + require.NoError(t, err) + + // Single-file mode uses "dotnet run