diff --git a/cli/azd/cmd/hooks.go b/cli/azd/cmd/hooks.go index 1cc582a05f1..6f4cce15ae4 100644 --- a/cli/azd/cmd/hooks.go +++ b/cli/azd/cmd/hooks.go @@ -54,7 +54,7 @@ func newHooksRunFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) func newHooksRunCmd() *cobra.Command { return &cobra.Command{ Use: "run ", - Short: "Runs the specified hook for the project and services", + Short: "Runs the specified hook for the project, provisioning layers, and services", Args: cobra.ExactArgs(1), } } @@ -62,6 +62,7 @@ func newHooksRunCmd() *cobra.Command { type hooksRunFlags struct { internal.EnvFlag global *internal.GlobalCommandOptions + layer string platform string service string } @@ -70,6 +71,7 @@ func (f *hooksRunFlags) Bind(local *pflag.FlagSet, global *internal.GlobalComman f.EnvFlag.Bind(local, global) f.global = global + local.StringVar(&f.layer, "layer", "", "Only runs hooks for the specified provisioning layer.") local.StringVar(&f.platform, "platform", "", "Forces hooks to run for the specified platform.") local.StringVar(&f.service, "service", "", "Only runs hooks for the specified service.") } @@ -114,6 +116,7 @@ type hookContextType string const ( hookContextProject hookContextType = "command" + hookContextLayer hookContextType = "layer" hookContextService hookContextType = "service" ) @@ -142,8 +145,20 @@ var knownHookNames = map[string]bool{ func (hra *hooksRunAction) Run(ctx context.Context) (*actions.ActionResult, error) { hookName := hra.args[0] + if hra.flags.service != "" && hra.flags.layer != "" { + return nil, + + &internal.ErrorWithSuggestion{ + Err: fmt.Errorf( + "--service and --layer cannot be used together: %w", internal.ErrInvalidFlagCombination), + Suggestion: "Choose either '--service' to run service hooks or '--layer' to run provisioning layer hooks.", + } + } + hookType := "project" - if hra.flags.service != "" { + if hra.flags.layer != "" { + hookType = "layer" + } else if hra.flags.service != "" { hookType = "service" } @@ -184,6 +199,12 @@ func (hra *hooksRunAction) Run(ctx context.Context) (*actions.ActionResult, erro } } + if hra.flags.layer != "" { + if _, err := hra.projectConfig.Infra.GetLayer(hra.flags.layer); err != nil { + return nil, err + } + } + // Project level hooks projectHooks := hra.projectConfig.Hooks[hookName] @@ -204,6 +225,24 @@ func (hra *hooksRunAction) Run(ctx context.Context) (*actions.ActionResult, erro return nil, err } + for _, layer := range hra.projectConfig.Infra.Layers { + layerPath := layer.AbsolutePath(hra.projectConfig.Path) + + skip := hra.flags.layer != "" && layer.Name != hra.flags.layer + + hra.console.Message(ctx, "\n"+output.WithHighLightFormat(fmt.Sprintf("Layer: %s", layer.Name))) + if err := hra.processHooks( + ctx, + layerPath, + hookName, + layer.Hooks[hookName], + hookContextLayer, + skip, + ); err != nil { + return nil, err + } + } + // Service level hooks for _, service := range stableServices { serviceHooks := service.Hooks[hookName] @@ -212,7 +251,7 @@ func (hra *hooksRunAction) Run(ctx context.Context) (*actions.ActionResult, erro hra.console.Message(ctx, "\n"+output.WithHighLightFormat(service.Name)) if err := hra.processHooks( ctx, - service.RelativePath, + service.Path(), hookName, serviceHooks, hookContextService, @@ -246,7 +285,7 @@ func (hra *hooksRunAction) processHooks( // When skipping, show individual skip messages for each hook that would have run for i := range hooks { hra.console.MessageUxItem(ctx, &ux.SkippedMessage{ - Message: fmt.Sprintf("service hook %d/%d", i+1, len(hooks)), + Message: fmt.Sprintf("%s hook %d/%d", contextType, i+1, len(hooks)), }) } @@ -312,37 +351,43 @@ func (hra *hooksRunAction) execHook( // Validates hooks and displays warnings for default shell usage and other issues func (hra *hooksRunAction) validateAndWarnHooks(ctx context.Context) error { - // Collect all hooks from project and services - allHooks := make(map[string][]*ext.HookConfig) + warningKeys := map[string]struct{}{} + validateAndWarn := func(cwd string, hooks map[string][]*ext.HookConfig) { + if len(hooks) == 0 { + return + } - // Add project hooks - for hookName, hookConfigs := range hra.projectConfig.Hooks { - allHooks[hookName] = append(allHooks[hookName], hookConfigs...) + hooksManager := ext.NewHooksManager(cwd, hra.commandRunner) + validationResult := hooksManager.ValidateHooks(ctx, hooks) + + for _, warning := range validationResult.Warnings { + key := warning.Message + "\x00" + warning.Suggestion + if _, has := warningKeys[key]; has { + continue + } + + warningKeys[key] = struct{}{} + hra.console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: warning.Message, + }) + if warning.Suggestion != "" { + hra.console.Message(ctx, warning.Suggestion) + } + hra.console.Message(ctx, "") + } } - // Add service hooks + validateAndWarn(hra.projectConfig.Path, hra.projectConfig.Hooks) + stableServices, err := hra.importManager.ServiceStable(ctx, hra.projectConfig) if err == nil { for _, service := range stableServices { - for hookName, hookConfigs := range service.Hooks { - allHooks[hookName] = append(allHooks[hookName], hookConfigs...) - } + validateAndWarn(service.Path(), service.Hooks) } } - // Create hooks manager and validate - hooksManager := ext.NewHooksManager(hra.projectConfig.Path, hra.commandRunner) - validationResult := hooksManager.ValidateHooks(ctx, allHooks) - - // Display any warnings - for _, warning := range validationResult.Warnings { - hra.console.MessageUxItem(ctx, &ux.WarningMessage{ - Description: warning.Message, - }) - if warning.Suggestion != "" { - hra.console.Message(ctx, warning.Suggestion) - } - hra.console.Message(ctx, "") + for _, layer := range hra.projectConfig.Infra.Layers { + validateAndWarn(layer.AbsolutePath(hra.projectConfig.Path), layer.Hooks) } return nil diff --git a/cli/azd/cmd/hooks_test.go b/cli/azd/cmd/hooks_test.go new file mode 100644 index 00000000000..5307c5cd80e --- /dev/null +++ b/cli/azd/cmd/hooks_test.go @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/internal/tracing" + "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" + "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/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/test/mocks" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockenv" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func Test_HooksRunAction_RunsLayerHooks(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + env := environment.NewWithValues("test", nil) + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, mock.Anything).Return(nil) + + projectPath := t.TempDir() + absoluteLayerPath := filepath.Join(t.TempDir(), "shared") + + projectConfig := &project.ProjectConfig{ + Name: "test", + Path: projectPath, + Services: map[string]*project.ServiceConfig{}, + Infra: provisioning.Options{ + Layers: []provisioning.Options{ + { + Name: "core", + Path: "infra/core", + Hooks: provisioning.HooksConfig{ + "preprovision": {{ + Shell: ext.ShellTypeBash, + Run: "echo core", + }}, + }, + }, + { + Name: "shared", + Path: absoluteLayerPath, + Hooks: provisioning.HooksConfig{ + "preprovision": {{ + Shell: ext.ShellTypeBash, + Run: "echo shared", + }}, + }, + }, + }, + }, + } + + var gotCwds []string + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return true + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + gotCwds = append(gotCwds, args.Cwd) + return exec.NewRunResult(0, "", ""), nil + }) + + action := &hooksRunAction{ + projectConfig: projectConfig, + env: env, + envManager: envManager, + importManager: project.NewImportManager(nil), + commandRunner: mockContext.CommandRunner, + console: mockContext.Console, + flags: &hooksRunFlags{}, + args: []string{"preprovision"}, + serviceLocator: mockContext.Container, + } + + result, err := action.Run(*mockContext.Context) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, []string{ + filepath.Join(projectPath, "infra/core"), + absoluteLayerPath, + }, gotCwds) +} + +func Test_HooksRunAction_FiltersLayerHooks(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + env := environment.NewWithValues("test", nil) + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, mock.Anything).Return(nil) + + projectPath := t.TempDir() + + projectConfig := &project.ProjectConfig{ + Name: "test", + Path: projectPath, + Services: map[string]*project.ServiceConfig{}, + Infra: provisioning.Options{ + Layers: []provisioning.Options{ + { + Name: "core", + Path: "infra/core", + Hooks: provisioning.HooksConfig{ + "preprovision": {{ + Shell: ext.ShellTypeBash, + Run: "echo core", + }}, + }, + }, + { + Name: "shared", + Path: "infra/shared", + Hooks: provisioning.HooksConfig{ + "preprovision": {{ + Shell: ext.ShellTypeBash, + Run: "echo shared", + }}, + }, + }, + }, + }, + } + + var gotCwds []string + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return true + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + gotCwds = append(gotCwds, args.Cwd) + return exec.NewRunResult(0, "", ""), nil + }) + + action := &hooksRunAction{ + projectConfig: projectConfig, + env: env, + envManager: envManager, + importManager: project.NewImportManager(nil), + commandRunner: mockContext.CommandRunner, + console: mockContext.Console, + flags: &hooksRunFlags{layer: "shared"}, + args: []string{"preprovision"}, + serviceLocator: mockContext.Container, + } + + result, err := action.Run(*mockContext.Context) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, []string{ + filepath.Join(projectPath, "infra/shared"), + }, gotCwds) +} + +func Test_HooksRunAction_SetsTelemetryTypeForLayer(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + env := environment.NewWithValues("test", nil) + envManager := &mockenv.MockEnvManager{} + envManager.On("Reload", mock.Anything, mock.Anything).Return(nil) + + t.Cleanup(func() { + tracing.SetUsageAttributes() + }) + tracing.SetUsageAttributes() + + projectConfig := &project.ProjectConfig{ + Name: "test", + Path: t.TempDir(), + Services: map[string]*project.ServiceConfig{}, + Infra: provisioning.Options{ + Layers: []provisioning.Options{ + { + Name: "core", + Path: "infra/core", + Hooks: provisioning.HooksConfig{ + "preprovision": {{ + Shell: ext.ShellTypeBash, + Run: "echo core", + }}, + }, + }, + }, + }, + } + + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return true + }).Respond(exec.NewRunResult(0, "", "")) + + action := &hooksRunAction{ + projectConfig: projectConfig, + env: env, + envManager: envManager, + importManager: project.NewImportManager(nil), + commandRunner: mockContext.CommandRunner, + console: mockContext.Console, + flags: &hooksRunFlags{layer: "core"}, + args: []string{"preprovision"}, + serviceLocator: mockContext.Container, + } + + _, err := action.Run(*mockContext.Context) + require.NoError(t, err) + + var hookType string + for _, attr := range tracing.GetUsageAttributes() { + if attr.Key == fields.HooksTypeKey.Key { + hookType = attr.Value.AsString() + break + } + } + + require.Equal(t, "layer", hookType) +} + +func Test_HooksRunAction_RejectsServiceAndLayerTogether(t *testing.T) { + action := &hooksRunAction{ + env: environment.NewWithValues("test", nil), + flags: &hooksRunFlags{service: "api", layer: "core"}, + args: []string{"preprovision"}, + } + + _, err := action.Run(context.Background()) + require.Error(t, err) + require.ErrorContains(t, err, "--service and --layer cannot be used together") +} + +func Test_HooksRunAction_ValidatesLayerHooksRelativeToLayerPath(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + env := environment.NewWithValues("test", nil) + + projectPath := t.TempDir() + layerScriptPath := filepath.Join(projectPath, "infra", "core", "scripts", "preprovision.sh") + require.NoError(t, os.MkdirAll(filepath.Dir(layerScriptPath), 0o755)) + require.NoError(t, os.WriteFile(layerScriptPath, []byte("echo pre"), 0o600)) + + layerHook := &ext.HookConfig{ + Run: filepath.Join("scripts", "preprovision.sh"), + } + + projectConfig := &project.ProjectConfig{ + Name: "test", + Path: projectPath, + Services: map[string]*project.ServiceConfig{}, + Infra: provisioning.Options{ + Layers: []provisioning.Options{ + { + Name: "core", + Path: filepath.Join("infra", "core"), + Hooks: provisioning.HooksConfig{ + "preprovision": {layerHook}, + }, + }, + }, + }, + } + + action := &hooksRunAction{ + projectConfig: projectConfig, + env: env, + importManager: project.NewImportManager(nil), + commandRunner: mockContext.CommandRunner, + console: mockContext.Console, + flags: &hooksRunFlags{}, + serviceLocator: mockContext.Container, + } + + err := action.validateAndWarnHooks(*mockContext.Context) + require.NoError(t, err) + require.False(t, layerHook.IsUsingDefaultShell()) + require.Equal(t, ext.ScriptTypeUnknown, layerHook.Shell) +} diff --git a/cli/azd/cmd/middleware/hooks.go b/cli/azd/cmd/middleware/hooks.go index e45dda6244e..6008d8af6c8 100644 --- a/cli/azd/cmd/middleware/hooks.go +++ b/cli/azd/cmd/middleware/hooks.go @@ -181,45 +181,41 @@ func (m *HooksMiddleware) createServiceEventHandler( // validateHooks validates hook configurations and displays any warnings func (m *HooksMiddleware) validateHooks(ctx context.Context, projectConfig *project.ProjectConfig) error { - // Get service hooks for validation - var serviceHooks []map[string][]*ext.HookConfig - stableServices, err := m.importManager.ServiceStable(ctx, projectConfig) - if err != nil { - return fmt.Errorf("failed getting services for hook validation: %w", err) - } + warningKeys := map[string]struct{}{} + validateAndWarn := func(cwd string, hooks map[string][]*ext.HookConfig) { + if len(hooks) == 0 { + return + } - for _, service := range stableServices { - serviceHooks = append(serviceHooks, service.Hooks) - } + hooksManager := ext.NewHooksManager(cwd, m.commandRunner) + validationResult := hooksManager.ValidateHooks(ctx, hooks) - // Combine project and service hooks into a single map - allHooks := make(map[string][]*ext.HookConfig) + for _, warning := range validationResult.Warnings { + key := warning.Message + "\x00" + warning.Suggestion + if _, has := warningKeys[key]; has { + continue + } - // Add project hooks - for hookName, hookConfigs := range projectConfig.Hooks { - allHooks[hookName] = append(allHooks[hookName], hookConfigs...) + warningKeys[key] = struct{}{} + m.console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: warning.Message, + }) + if warning.Suggestion != "" { + m.console.Message(ctx, warning.Suggestion) + } + m.console.Message(ctx, "") + } } - // Add service hooks - for _, serviceHookMap := range serviceHooks { - for hookName, hookConfigs := range serviceHookMap { - allHooks[hookName] = append(allHooks[hookName], hookConfigs...) - } + validateAndWarn(projectConfig.Path, projectConfig.Hooks) + + stableServices, err := m.importManager.ServiceStable(ctx, projectConfig) + if err != nil { + return fmt.Errorf("failed getting services for hook validation: %w", err) } - // Create hooks manager and validate - hooksManager := ext.NewHooksManager(projectConfig.Path, m.commandRunner) - validationResult := hooksManager.ValidateHooks(ctx, allHooks) - - // Display any warnings - for _, warning := range validationResult.Warnings { - m.console.MessageUxItem(ctx, &ux.WarningMessage{ - Description: warning.Message, - }) - if warning.Suggestion != "" { - m.console.Message(ctx, warning.Suggestion) - } - m.console.Message(ctx, "") + for _, service := range stableServices { + validateAndWarn(service.Path(), service.Hooks) } return nil diff --git a/cli/azd/cmd/middleware/hooks_test.go b/cli/azd/cmd/middleware/hooks_test.go index df1c614fbae..524677bf5b2 100644 --- a/cli/azd/cmd/middleware/hooks_test.go +++ b/cli/azd/cmd/middleware/hooks_test.go @@ -6,7 +6,10 @@ package middleware import ( "context" "errors" + "os" osexec "os/exec" + "path/filepath" + "runtime" "strings" "testing" @@ -288,6 +291,81 @@ func Test_ServiceHooks_Registered(t *testing.T) { require.Equal(t, 1, preDeployCount) } +func Test_ServiceHooks_ValidationUsesServicePath(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + azdContext := createAzdContext(t) + + envName := "test" + runOptions := Options{CommandPath: "deploy"} + + projectConfig := project.ProjectConfig{ + Name: envName, + Services: map[string]*project.ServiceConfig{}, + } + + hookPath := filepath.Join("scripts", "predeploy.ps1") + expectedShell := "pwsh" + scriptContents := "Write-Host 'Hello'\n" + if runtime.GOOS == "windows" { + hookPath = filepath.Join("scripts", "predeploy.sh") + expectedShell = "bash" + scriptContents = "echo hello\n" + } + + serviceConfig := &project.ServiceConfig{ + EventDispatcher: ext.NewEventDispatcher[project.ServiceLifecycleEventArgs](project.ServiceEvents...), + Language: "ts", + RelativePath: "./src/api", + Host: "appservice", + Hooks: map[string][]*ext.HookConfig{ + "predeploy": { + { + Run: hookPath, + }, + }, + }, + } + + projectConfig.Services["api"] = serviceConfig + + err := ensureAzdValid(mockContext, azdContext, envName, &projectConfig) + require.NoError(t, err) + + projectConfig.Services["api"].Project = &projectConfig + + serviceHookPath := filepath.Join(serviceConfig.Path(), hookPath) + require.NoError(t, os.MkdirAll(filepath.Dir(serviceHookPath), 0o755)) + require.NoError(t, os.WriteFile(serviceHookPath, []byte(scriptContents), 0o600)) + + mockContext.CommandRunner.MockToolInPath("pwsh", nil) + + var executedShell string + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return true + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + executedShell = args.Cmd + return exec.NewRunResult(0, "", ""), nil + }) + + nextFn := func(ctx context.Context) (*actions.ActionResult, error) { + err := serviceConfig.Invoke(ctx, project.ServiceEventDeploy, project.ServiceLifecycleEventArgs{ + Project: &projectConfig, + Service: serviceConfig, + ServiceContext: project.NewServiceContext(), + }, func() error { + return nil + }) + + return &actions.ActionResult{}, err + } + + result, err := runMiddleware(mockContext, envName, &projectConfig, &runOptions, nextFn) + + require.NotNil(t, result) + require.NoError(t, err) + require.Equal(t, expectedShell, executedShell) +} + func createAzdContext(t *testing.T) *azdcontext.AzdContext { tempDir := t.TempDir() ostest.Chdir(t, tempDir) diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 1070af51d1b..76c200fecd0 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -2276,7 +2276,7 @@ const completionSpec: Fig.Spec = { subcommands: [ { name: ['run'], - description: 'Runs the specified hook for the project and services', + description: 'Runs the specified hook for the project, provisioning layers, and services', options: [ { name: ['--environment', '-e'], @@ -2287,6 +2287,15 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--layer'], + description: 'Only runs hooks for the specified provisioning layer.', + args: [ + { + name: 'layer', + }, + ], + }, { name: ['--platform'], description: 'Forces hooks to run for the specified platform.', @@ -3543,7 +3552,7 @@ const completionSpec: Fig.Spec = { subcommands: [ { name: ['run'], - description: 'Runs the specified hook for the project and services', + description: 'Runs the specified hook for the project, provisioning layers, and services', }, ], }, diff --git a/cli/azd/cmd/testdata/TestUsage-azd-hooks-run.snap b/cli/azd/cmd/testdata/TestUsage-azd-hooks-run.snap index 323709bfe38..e51a58ac200 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-hooks-run.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-hooks-run.snap @@ -1,11 +1,12 @@ -Runs the specified hook for the project and services +Runs the specified hook for the project, provisioning layers, and services Usage azd hooks run [flags] Flags -e, --environment string : The name of the environment to use. + --layer string : Only runs hooks for the specified provisioning layer. --platform string : Forces hooks to run for the specified platform. --service string : Only runs hooks for the specified service. diff --git a/cli/azd/cmd/testdata/TestUsage-azd-hooks.snap b/cli/azd/cmd/testdata/TestUsage-azd-hooks.snap index 561f1d53d7f..93883b79116 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-hooks.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-hooks.snap @@ -5,7 +5,7 @@ Usage azd hooks [command] Available Commands - run : Runs the specified hook for the project and services + run : Runs the specified hook for the project, provisioning layers, and services Global Flags -C, --cwd string : Sets the current working directory. diff --git a/cli/azd/internal/cmd/provision.go b/cli/azd/internal/cmd/provision.go index 03ef4c2c1e7..cf76b0a0f62 100644 --- a/cli/azd/internal/cmd/provision.go +++ b/cli/azd/internal/cmd/provision.go @@ -20,8 +20,11 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/cloud" "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/ext" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "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/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" @@ -132,6 +135,8 @@ type ProvisionAction struct { projectConfig *project.ProjectConfig writer io.Writer console input.Console + commandRunner exec.CommandRunner + serviceLocator ioc.ServiceLocator subManager *account.SubscriptionsManager importManager *project.ImportManager alphaFeatureManager *alpha.FeatureManager @@ -149,6 +154,8 @@ func NewProvisionAction( env *environment.Environment, envManager environment.Manager, console input.Console, + commandRunner exec.CommandRunner, + serviceLocator ioc.ServiceLocator, formatter output.Formatter, writer io.Writer, subManager *account.SubscriptionsManager, @@ -167,6 +174,8 @@ func NewProvisionAction( projectConfig: projectConfig, writer: writer, console: console, + commandRunner: commandRunner, + serviceLocator: serviceLocator, subManager: subManager, importManager: importManager, alphaFeatureManager: alphaFeatureManager, @@ -280,6 +289,8 @@ func (p *ProvisionAction) Run(ctx context.Context) (*actions.ActionResult, error allSkipped := true for i, layer := range layers { + layerPath := layer.AbsolutePath(p.projectConfig.Path) + layer.IgnoreDeploymentState = p.flags.ignoreDeploymentState if err := p.provisionManager.Initialize(ctx, p.projectConfig.Path, layer); err != nil { return nil, fmt.Errorf("initializing provisioning manager: %w", err) @@ -331,20 +342,27 @@ func (p *ProvisionAction) Run(ctx context.Context) (*actions.ActionResult, error projectEventArgs := project.ProjectLifecycleEventArgs{ Project: p.projectConfig, + Args: map[string]any{ + "preview": previewMode, + "layer": layer.Name, + "path": layerPath, + }, } if p.alphaFeatureManager.IsEnabled(azapi.FeatureDeploymentStacks) { p.console.WarnForFeature(ctx, azapi.FeatureDeploymentStacks) } - // Do not raise pre/postprovision events in preview mode + // Do not raise pre/postprovision events in preview mode. if previewMode { deployPreviewResult, err = p.provisionManager.Preview(ctx) } else { - err = p.projectConfig.Invoke(ctx, project.ProjectEventProvision, projectEventArgs, func() error { - var err error - deployResult, err = p.provisionManager.Deploy(ctx) - return err + err = p.runLayerProvisionWithHooks(ctx, layer, layerPath, func() error { + return p.projectConfig.Invoke(ctx, project.ProjectEventProvision, projectEventArgs, func() error { + var err error + deployResult, err = p.provisionManager.Deploy(ctx) + return err + }) }) } @@ -507,6 +525,59 @@ func (p *ProvisionAction) Run(ctx context.Context) (*actions.ActionResult, error }, nil } +func (p *ProvisionAction) runLayerProvisionWithHooks( + ctx context.Context, + layer provisioning.Options, + layerPath string, + actionFn ext.InvokeFn, +) error { + if len(layer.Hooks) == 0 { + return actionFn() + } + + hooksManager := ext.NewHooksManager(layerPath, p.commandRunner) + hooksRunner := ext.NewHooksRunner( + hooksManager, + p.commandRunner, + p.envManager, + p.console, + layerPath, + layer.Hooks, + p.env, + p.serviceLocator, + ) + + p.validateAndWarnLayerHooks(ctx, hooksManager, layer.Hooks) + + if err := hooksRunner.Invoke(ctx, []string{string(project.ProjectEventProvision)}, actionFn); err != nil { + if layer.Name == "" { + return err + } + + return fmt.Errorf("layer '%s': %w", layer.Name, err) + } + + return nil +} + +func (p *ProvisionAction) validateAndWarnLayerHooks( + ctx context.Context, + hooksManager *ext.HooksManager, + hooks map[string][]*ext.HookConfig, +) { + validationResult := hooksManager.ValidateHooks(ctx, hooks) + + for _, warning := range validationResult.Warnings { + p.console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: warning.Message, + }) + if warning.Suggestion != "" { + p.console.Message(ctx, warning.Suggestion) + } + p.console.Message(ctx, "") + } +} + // deployResultToUx creates the ux element to display from a provision preview func deployResultToUx(previewResult *provisioning.DeployPreviewResult) ux.UxItem { var operations []*ux.Resource diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index 5a73962ccf8..97952b6968f 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -345,7 +345,7 @@ var ( Classification: SystemMetadata, Purpose: FeatureInsight, } - // The type of the hook (project or service). + // The type of the hook run scope (project, layer, or service). HooksTypeKey = AttributeKey{ Key: attribute.Key("hooks.type"), Classification: SystemMetadata, diff --git a/cli/azd/pkg/ext/hooks_config.go b/cli/azd/pkg/ext/hooks_config.go new file mode 100644 index 00000000000..35bceab3e5e --- /dev/null +++ b/cli/azd/pkg/ext/hooks_config.go @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ext + +import "fmt" + +// HooksConfig is an alias for map of hook names to slices of hook configurations. +// It supports unmarshalling both legacy single-hook and newer multi-hook formats. +type HooksConfig map[string][]*HookConfig + +// UnmarshalYAML converts hook configuration from YAML, supporting both single-hook configuration +// and multiple-hooks configuration. +func (ch *HooksConfig) UnmarshalYAML(unmarshal func(any) error) error { + var legacyConfig map[string]*HookConfig + + if err := unmarshal(&legacyConfig); err == nil { + newConfig := HooksConfig{} + + for key, value := range legacyConfig { + newConfig[key] = []*HookConfig{value} + } + + *ch = newConfig + return nil + } + + var newConfig map[string][]*HookConfig + if err := unmarshal(&newConfig); err != nil { + return fmt.Errorf("failed to unmarshal hooks configuration: %w", err) + } + + *ch = newConfig + + return nil +} + +// MarshalYAML marshals hook configuration to YAML, supporting both single-hook configuration +// and multiple-hooks configuration. +func (ch HooksConfig) MarshalYAML() (any, error) { + if len(ch) == 0 { + return nil, nil + } + + result := map[string]any{} + for key, hooks := range ch { + if len(hooks) == 1 { + result[key] = hooks[0] + } else { + result[key] = hooks + } + } + + return result, nil +} diff --git a/cli/azd/pkg/ext/hooks_config_test.go b/cli/azd/pkg/ext/hooks_config_test.go new file mode 100644 index 00000000000..b298553dea2 --- /dev/null +++ b/cli/azd/pkg/ext/hooks_config_test.go @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestHooksConfig_UnmarshalYAML(t *testing.T) { + t.Run("legacy single hook format", func(t *testing.T) { + const doc = ` +preprovision: + shell: sh + run: scripts/preprovision.sh +` + + var hooks HooksConfig + err := yaml.Unmarshal([]byte(doc), &hooks) + require.NoError(t, err) + + require.Equal(t, HooksConfig{ + "preprovision": { + { + Shell: ShellTypeBash, + Run: "scripts/preprovision.sh", + }, + }, + }, hooks) + }) + + t.Run("multiple hook format", func(t *testing.T) { + const doc = ` +preprovision: + - shell: sh + run: scripts/preprovision-1.sh + - shell: sh + run: scripts/preprovision-2.sh +` + + var hooks HooksConfig + err := yaml.Unmarshal([]byte(doc), &hooks) + require.NoError(t, err) + + require.Equal(t, HooksConfig{ + "preprovision": { + { + Shell: ShellTypeBash, + Run: "scripts/preprovision-1.sh", + }, + { + Shell: ShellTypeBash, + Run: "scripts/preprovision-2.sh", + }, + }, + }, hooks) + }) +} + +func TestHooksConfig_MarshalYAML(t *testing.T) { + t.Run("single hook emits object", func(t *testing.T) { + hooks := HooksConfig{ + "preprovision": { + { + Shell: ShellTypeBash, + Run: "scripts/preprovision.sh", + }, + }, + } + + data, err := yaml.Marshal(hooks) + require.NoError(t, err) + + assert.YAMLEq(t, ` +preprovision: + shell: sh + run: scripts/preprovision.sh +`, string(data)) + }) + + t.Run("multiple hooks emit sequence", func(t *testing.T) { + hooks := HooksConfig{ + "preprovision": { + { + Shell: ShellTypeBash, + Run: "scripts/preprovision-1.sh", + }, + { + Shell: ShellTypeBash, + Run: "scripts/preprovision-2.sh", + }, + }, + } + + data, err := yaml.Marshal(hooks) + require.NoError(t, err) + + assert.YAMLEq(t, ` +preprovision: + - shell: sh + run: scripts/preprovision-1.sh + - shell: sh + run: scripts/preprovision-2.sh +`, string(data)) + }) +} diff --git a/cli/azd/pkg/infra/provisioning/provider.go b/cli/azd/pkg/infra/provisioning/provider.go index 4da7dfbe1a8..bc46e77c1f1 100644 --- a/cli/azd/pkg/infra/provisioning/provider.go +++ b/cli/azd/pkg/infra/provisioning/provider.go @@ -6,11 +6,13 @@ package provisioning import ( "context" "fmt" + "path/filepath" "strings" "dario.cat/mergo" "github.com/azure/azure-dev/cli/azd/internal/tracing" "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" + "github.com/azure/azure-dev/cli/azd/pkg/ext" ) type ProviderKind string @@ -39,6 +41,7 @@ type Options struct { Path string `yaml:"path,omitempty"` Module string `yaml:"module,omitempty"` Name string `yaml:"name,omitempty"` + Hooks HooksConfig `yaml:"hooks,omitempty"` DeploymentStacks map[string]any `yaml:"deploymentStacks,omitempty"` // Provisioning options for each individually defined layer. Layers []Options `yaml:"layers,omitempty"` @@ -51,6 +54,9 @@ type Options struct { Mode Mode `yaml:"-"` } +// HooksConfig aliases ext.HooksConfig for compatibility with existing provisioning package references. +type HooksConfig = ext.HooksConfig + // GetWithDefaults merges the provided infra options with the default provisioning options func (o Options) GetWithDefaults(other ...Options) (Options, error) { mergedOptions := Options{} @@ -75,6 +81,15 @@ func (o Options) GetWithDefaults(other ...Options) (Options, error) { return mergedOptions, nil } +// AbsolutePath returns the layer path resolved against the project path when needed. +func (o Options) AbsolutePath(projectPath string) string { + if filepath.IsAbs(o.Path) { + return o.Path + } + + return filepath.Join(projectPath, o.Path) +} + // GetLayers return the provisioning layers defined. // When [Options.Layers] is not defined, it returns the single layer defined. // @@ -116,26 +131,69 @@ func (o *Options) GetLayer(name string) (Options, error) { // // This should be called immediately right after Unmarshal() before any defaulting is performed. func (o *Options) Validate() error { - errWrap := func(err string) error { - return fmt.Errorf("validating infra.layers: %s", err) + if len(o.Hooks) > 0 { + return validateErr("infra", "'hooks' can only be declared under 'infra.layers[]'") } - anyIncompatibleFieldsSet := func() bool { - return o.Name != "" || o.Module != "" || o.Path != "" || o.DeploymentStacks != nil + if len(o.Layers) > 0 { + anyIncompatibleFieldsSet := func() bool { + return o.Name != "" || o.Module != "" || o.Path != "" || o.DeploymentStacks != nil + } + + if anyIncompatibleFieldsSet() { + return validateErr("infra", "properties on 'infra' cannot be declared when 'infra.layers' is declared") + } + + if err := o.validateLayers(); err != nil { + return wrapValidateErr("infra.layers", err) + } + } + + return nil +} + +func wrapValidateErr(scope string, err error) error { + if err == nil { + return nil } - if len(o.Layers) > 0 && anyIncompatibleFieldsSet() { - return errWrap( - "properties on 'infra' cannot be declared when 'infra.layers' is declared") + return fmt.Errorf("validating %s: %w", scope, err) +} + +func validateErr(scope, format string, args ...any) error { + return wrapValidateErr(scope, fmt.Errorf(format, args...)) +} + +func (o *Options) validateLayers() error { + validateHooks := func(scope string, hooks HooksConfig) error { + for hookName := range hooks { + hookType, eventName := ext.InferHookType(hookName) + if hookType == ext.HookTypeNone || eventName != "provision" { + return fmt.Errorf("%s: only 'preprovision' and 'postprovision' hooks are supported", scope) + } + } + + return nil } + seenLayers := map[string]struct{}{} for _, layer := range o.Layers { if layer.Name == "" { - return errWrap("name must be specified for each provisioning layer") + return fmt.Errorf("name must be specified for each provisioning layer") + } + + if _, has := seenLayers[layer.Name]; has { + return fmt.Errorf("duplicate layer name '%s' is not allowed", layer.Name) } + seenLayers[layer.Name] = struct{}{} + if layer.Path == "" { - return errWrap(fmt.Sprintf("%s: path must be specified", layer.Name)) + return fmt.Errorf("%s: path must be specified", layer.Name) + } + + if err := validateHooks(layer.Name, layer.Hooks); err != nil { + return err } } diff --git a/cli/azd/pkg/infra/provisioning/provider_test.go b/cli/azd/pkg/infra/provisioning/provider_test.go index c42f974396c..3ec26764390 100644 --- a/cli/azd/pkg/infra/provisioning/provider_test.go +++ b/cli/azd/pkg/infra/provisioning/provider_test.go @@ -4,6 +4,7 @@ package provisioning import ( + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -262,6 +263,39 @@ func TestOptions_GetWithDefaults(t *testing.T) { } } +func TestOptions_AbsolutePath(t *testing.T) { + rootPath := t.TempDir() + projectPath := filepath.Join(rootPath, "project") + absoluteLayerPath := filepath.Join(rootPath, "shared", "infra") + + tests := []struct { + name string + options Options + expected string + }{ + { + name: "resolves relative path against project path", + options: Options{ + Path: filepath.Join("infra", "core"), + }, + expected: filepath.Join(projectPath, "infra", "core"), + }, + { + name: "keeps absolute path", + options: Options{ + Path: absoluteLayerPath, + }, + expected: absoluteLayerPath, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.options.AbsolutePath(projectPath)) + }) + } +} + func TestOptions_GetWithDefaults_MergePrecedence(t *testing.T) { // Save original defaultOptions and restore after tests originalDefaults := defaultOptions @@ -353,3 +387,98 @@ func TestOptions_GetWithDefaults_EmptyVariations(t *testing.T) { assert.Equal(t, "infra", result.Path) }) } + +func TestOptions_Validate_Hooks(t *testing.T) { + t.Run("valid layer hooks", func(t *testing.T) { + err := (&Options{ + Layers: []Options{ + { + Name: "infra-core", + Path: "infra/core", + Hooks: HooksConfig{ + "preprovision": { + {Run: "echo pre"}, + }, + "postprovision": { + {Run: "echo post"}, + }, + }, + }, + }, + }).Validate() + + require.NoError(t, err) + }) + + t.Run("invalid layer hook event", func(t *testing.T) { + err := (&Options{ + Layers: []Options{ + { + Name: "infra-core", + Path: "infra/core", + Hooks: HooksConfig{ + "predeploy": { + {Run: "echo pre"}, + }, + }, + }, + }, + }).Validate() + + require.EqualError( + t, + err, + "validating infra.layers: infra-core: only 'preprovision' and 'postprovision' hooks are supported", + ) + }) + + t.Run("duplicate layer names are not allowed", func(t *testing.T) { + err := (&Options{ + Layers: []Options{ + { + Name: "infra-core", + Path: "infra/core", + }, + { + Name: "infra-core", + Path: "infra/shared", + }, + }, + }).Validate() + + require.Error(t, err) + require.ErrorContains(t, err, "duplicate layer name 'infra-core' is not allowed") + }) + + t.Run("layers cannot be mixed with root hooks", func(t *testing.T) { + err := (&Options{ + Path: "infra", + Hooks: HooksConfig{ + "preprovision": { + {Run: "echo root"}, + }, + }, + Layers: []Options{ + { + Name: "infra-core", + Path: "infra/core", + }, + }, + }).Validate() + + require.EqualError(t, err, "validating infra: 'hooks' can only be declared under 'infra.layers[]'") + }) + + t.Run("root infra hooks are not allowed", func(t *testing.T) { + err := (&Options{ + Path: "infra", + Hooks: HooksConfig{ + "preprovision": { + {Run: "echo root"}, + }, + }, + }).Validate() + + require.EqualError(t, err, "validating infra: 'hooks' can only be declared under 'infra.layers[]'") + }) +} diff --git a/cli/azd/pkg/project/project_config.go b/cli/azd/pkg/project/project_config.go index 2e5a378e187..ce44883c508 100644 --- a/cli/azd/pkg/project/project_config.go +++ b/cli/azd/pkg/project/project_config.go @@ -5,7 +5,6 @@ package project import ( "context" - "fmt" "github.com/azure/azure-dev/cli/azd/pkg/cloud" "github.com/azure/azure-dev/cli/azd/pkg/ext" @@ -78,51 +77,5 @@ type ProjectMetadata struct { Template string } -// HooksConfig is an alias for map of hook names to slice of hook configurations -// This custom alias type is used to help support YAML unmarshalling of legacy single hook configurations -// and new multiple hook configurations -type HooksConfig map[string][]*ext.HookConfig - -// UnmarshalYAML converts the hooks configuration from YAML supporting both legacy single hook configurations -// and new multiple hook configurations -func (ch *HooksConfig) UnmarshalYAML(unmarshal func(any) error) error { - var legacyConfig map[string]*ext.HookConfig - - // Attempt to unmarshal the legacy single hook configuration - if err := unmarshal(&legacyConfig); err == nil { - newConfig := HooksConfig{} - - for key, value := range legacyConfig { - newConfig[key] = []*ext.HookConfig{value} - } - - *ch = newConfig - } else { // Unmarshal the new multiple hook configuration - var newConfig map[string][]*ext.HookConfig - if err := unmarshal(&newConfig); err != nil { - return fmt.Errorf("failed to unmarshal hooks configuration: %w", err) - } - - *ch = newConfig - } - - return nil -} - -// MarshalYAML marshals the hooks configuration to YAML supporting both legacy single hook configurations -func (ch HooksConfig) MarshalYAML() (any, error) { - if len(ch) == 0 { - return nil, nil - } - - result := map[string]any{} - for key, hooks := range ch { - if len(hooks) == 1 { - result[key] = hooks[0] - } else { - result[key] = hooks - } - } - - return result, nil -} +// HooksConfig aliases ext.HooksConfig for compatibility with existing project package references. +type HooksConfig = ext.HooksConfig diff --git a/cli/azd/test/functional/hooks_test.go b/cli/azd/test/functional/hooks_test.go new file mode 100644 index 00000000000..738b20c7b6f --- /dev/null +++ b/cli/azd/test/functional/hooks_test.go @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cli_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/test/azdcli" + "github.com/stretchr/testify/require" +) + +func Test_CLI_Hooks_RegistrationAndRun(t *testing.T) { + t.Parallel() + + ctx, cancel := newTestContext(t) + defer cancel() + + dir := tempDirWithDiagnostics(t) + t.Logf("DIR: %s", dir) + + envName := randomEnvName() + t.Logf("AZURE_ENV_NAME: %s", envName) + + deployTracePath := filepath.Join(dir, "deploy-trace.log") + provisionTracePath := filepath.Join(dir, "provision-trace.log") + + cli := azdcli.NewCLI(t) + cli.WorkingDirectory = dir + cli.Env = append(cli.Env, os.Environ()...) + cli.Env = append(cli.Env, fmt.Sprintf("AZURE_LOCATION=%s", cfg.Location)) + cli.Env = append(cli.Env, fmt.Sprintf("AZURE_SUBSCRIPTION_ID=%s", cfg.SubscriptionID)) + + err := copySample(dir, "hooks") + require.NoError(t, err, "failed expanding sample") + + _, err = cli.RunCommandWithStdIn(ctx, stdinForInit(envName), "init") + require.NoError(t, err) + + cli.Env = append(cli.Env, fmt.Sprintf("HOOK_TRACE_FILE=%s", deployTracePath)) + require.NoError(t, os.WriteFile(deployTracePath, []byte{}, 0600)) + _, err = cli.RunCommand(ctx, "deploy") + require.Error(t, err, "deploy should fail for this hooks sample") + + cli.Env = append(cli.Env, fmt.Sprintf("HOOK_TRACE_FILE=%s", provisionTracePath)) + require.NoError(t, os.WriteFile(provisionTracePath, []byte{}, 0600)) + _, err = cli.RunCommand(ctx, "provision") + require.Error(t, err, "provision should fail for this hooks sample") + + require.Equal(t, []string{ + "command-predeploy", + "service-prerestore", + }, readTraceEntries(t, deployTracePath)) + + require.Equal(t, []string{ + "command-preprovision", + "layer-preprovision", + }, readTraceEntries(t, provisionTracePath)) +} + +func Test_CLI_Hooks_Run_RegistrationAndRun(t *testing.T) { + t.Parallel() + + t.Run("RunAll", func(t *testing.T) { + traceEntries, err := runLocalHooksCommand(t, "predeploy") + require.NoError(t, err) + + require.Equal(t, []string{ + "command-predeploy", + "service-predeploy", + }, traceEntries) + }) + + t.Run("RunSpecific", func(t *testing.T) { + t.Run("Service", func(t *testing.T) { + traceEntries, err := runLocalHooksCommand(t, "predeploy", "--service", "app") + require.NoError(t, err) + + require.Equal(t, []string{ + "command-predeploy", + "service-predeploy", + }, traceEntries) + }) + + t.Run("Layer", func(t *testing.T) { + traceEntries, err := runLocalHooksCommand(t, "preprovision", "--layer", "core") + require.Error(t, err) + + require.Equal(t, []string{ + "command-preprovision", + "layer-preprovision", + }, traceEntries) + }) + }) +} + +func runLocalHooksCommand(t *testing.T, args ...string) ([]string, error) { + t.Helper() + + ctx, cancel := newTestContext(t) + defer cancel() + + dir := tempDirWithDiagnostics(t) + t.Logf("DIR: %s", dir) + + envName := randomEnvName() + t.Logf("AZURE_ENV_NAME: %s", envName) + + cli := azdcli.NewCLI(t) + cli.WorkingDirectory = dir + cli.Env = append(cli.Env, os.Environ()...) + + tracePath := filepath.Join(dir, "hooks-run-trace.log") + require.NoError(t, os.WriteFile(tracePath, []byte{}, 0600)) + + err := copySample(dir, "hooks") + require.NoError(t, err, "failed expanding sample") + + _, err = cli.RunCommandWithStdIn(ctx, stdinForInit(envName), "init") + require.NoError(t, err) + + cli.Env = append(cli.Env, fmt.Sprintf("HOOK_TRACE_FILE=%s", tracePath)) + command := append([]string{"hooks", "run"}, args...) + _, err = cli.RunCommand(ctx, command...) + + return readTraceEntries(t, tracePath), err +} + +func readTraceEntries(t *testing.T, tracePath string) []string { + t.Helper() + + traceBytes, err := os.ReadFile(tracePath) + require.NoError(t, err) + + var traceEntries []string + for line := range strings.SplitSeq(string(traceBytes), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + traceEntries = append(traceEntries, line) + } + + return traceEntries +} diff --git a/cli/azd/test/functional/testdata/samples/funcapp copy/up.log b/cli/azd/test/functional/testdata/samples/funcapp copy/up.log deleted file mode 100644 index 330fdfe89eb..00000000000 --- a/cli/azd/test/functional/testdata/samples/funcapp copy/up.log +++ /dev/null @@ -1,14 +0,0 @@ -2026/02/12 14:35:31 main.go:60: azd version: 0.0.0-dev.0 (commit 0000000000000000000000000000000000000000) -2026/02/12 14:35:31 detect_process.go:56: detect_process.go: Parent process detection: depth=0, pid=73536, ppid=33753, name="/opt/homebrew/bi", executable="/opt/homebrew/bin/fish" -2026/02/12 14:35:31 detect_process.go:56: detect_process.go: Parent process detection: depth=1, pid=33753, ppid=33551, name="/Applications/Vi", executable="/Applications/Visual" -2026/02/12 14:35:31 detect_process.go:56: detect_process.go: Parent process detection: depth=2, pid=33551, ppid=1, name="/Applications/Vi", executable="/Applications/Visual" -2026/02/12 14:35:31 detect_process.go:72: detect_process.go: Parent process detection: no agent found in process tree -2026/02/12 14:35:31 detect.go:25: Agent detection result: detected=false, no AI coding agent detected -2026/02/12 14:35:31 main.go:228: using cached latest version: 1.23.4 (expires on: 2026-02-13T22:18:45Z) -2026/02/12 14:35:31 middleware.go:100: running middleware 'debug' -2026/02/12 14:35:31 middleware.go:100: running middleware 'ux' -2026/02/12 14:35:31 middleware.go:100: running middleware 'telemetry' -2026/02/12 14:35:31 telemetry.go:66: TraceID: a8c353d7cf2be1bd0d78e51af0841dcc -2026/02/12 14:35:31 middleware.go:100: running middleware 'error' -2026/02/12 14:35:31 middleware.go:100: running middleware 'loginGuard' -2026/02/12 14:35:31 main.go:102: eliding update message for dev build diff --git a/cli/azd/test/functional/testdata/samples/hooks/azure.yaml b/cli/azd/test/functional/testdata/samples/hooks/azure.yaml new file mode 100644 index 00000000000..e84389bdc1b --- /dev/null +++ b/cli/azd/test/functional/testdata/samples/hooks/azure.yaml @@ -0,0 +1,51 @@ +name: hooks + +infra: + layers: + - name: core + path: infra + hooks: + preprovision: + windows: + run: 'Add-Content -Path $env:HOOK_TRACE_FILE -Value "layer-preprovision"; exit 1' + shell: pwsh + posix: + run: 'echo layer-preprovision >> "$HOOK_TRACE_FILE"; exit 1' + shell: sh + +hooks: + preprovision: + windows: + run: 'Add-Content -Path $env:HOOK_TRACE_FILE -Value "command-preprovision"' + shell: pwsh + posix: + run: 'echo command-preprovision >> "$HOOK_TRACE_FILE"' + shell: sh + predeploy: + windows: + run: 'Add-Content -Path $env:HOOK_TRACE_FILE -Value "command-predeploy"' + shell: pwsh + posix: + run: 'echo command-predeploy >> "$HOOK_TRACE_FILE"' + shell: sh + +services: + app: + project: . + host: appservice + language: js + hooks: + predeploy: + windows: + run: 'Add-Content -Path $env:HOOK_TRACE_FILE -Value "service-predeploy"' + shell: pwsh + posix: + run: 'echo service-predeploy >> "$HOOK_TRACE_FILE"' + shell: sh + prerestore: + windows: + run: 'Add-Content -Path $env:HOOK_TRACE_FILE -Value "service-prerestore"' + shell: pwsh + posix: + run: 'echo service-prerestore >> "$HOOK_TRACE_FILE"' + shell: sh diff --git a/cli/azd/test/functional/testdata/samples/hooks/infra/main.bicep b/cli/azd/test/functional/testdata/samples/hooks/infra/main.bicep new file mode 100644 index 00000000000..74350673ace --- /dev/null +++ b/cli/azd/test/functional/testdata/samples/hooks/infra/main.bicep @@ -0,0 +1,5 @@ +targetScope = 'subscription' + +param location string + +output noop string = location diff --git a/cli/azd/test/functional/testdata/samples/hooks/infra/main.parameters.json b/cli/azd/test/functional/testdata/samples/hooks/infra/main.parameters.json new file mode 100644 index 00000000000..4d4c914c7ea --- /dev/null +++ b/cli/azd/test/functional/testdata/samples/hooks/infra/main.parameters.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "location": { + "value": "${AZURE_LOCATION}" + } + } +} diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index d5f2e179393..df6c05cab06 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -67,7 +67,6 @@ "type": "array", "title": "Provisioning layers.", "description": "Optional. Layers for Azure infrastructure provisioning.", - "additionalProperties": false, "uniqueItems": true, "items": { "type": "object", @@ -95,7 +94,25 @@ }, "deploymentStacks": { "$ref": "#/definitions/deploymentStacksConfig" - } + }, + "hooks": { + "type": "object", + "title": "Provisioning layer hooks", + "description": "Hooks should match `provision` event names prefixed with `pre` or `post` depending on when the script should execute. When specifying paths they should be relative to the layer path.", + "additionalProperties": false, + "properties": { + "preprovision": { + "title": "pre provision hook", + "description": "Runs before provisioning the layer", + "$ref": "#/definitions/hooks" + }, + "postprovision": { + "title": "post provision hook", + "description": "Runs after provisioning the layer", + "$ref": "#/definitions/hooks" + } + } + } } }, "allOf": [ diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index ccc1e3a877a..bfdf11f9535 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -59,8 +59,79 @@ "type": "string", "title": "Name of the default module within the Azure provisioning templates", "description": "Optional. The name of the Azure provisioning module used when provisioning resources. (Default: main)" + }, + "layers": { + "type": "array", + "title": "Provisioning layers.", + "description": "Optional. Layers for Azure infrastructure provisioning.", + "uniqueItems": true, + "items": { + "type": "object", + "title": "A provisioning layer.", + "additionalProperties": false, + "required": [ + "name", + "path" + ], + "properties": { + "name": { + "type": "string", + "title": "The name of the provisioning layer", + "description": "The name of the provisioning layer" + }, + "path": { + "type": "string", + "title": "Path to the location that contains Azure provisioning templates", + "description": "The relative folder path to the Azure provisioning templates for the specified provider." + }, + "module": { + "type": "string", + "title": "Name of the default module within the Azure provisioning templates", + "description": "Optional. The name of the Azure provisioning module used when provisioning resources. (Default: main)" + }, + "hooks": { + "type": "object", + "title": "Provisioning layer hooks", + "description": "Hooks should match `provision` event names prefixed with `pre` or `post` depending on when the script should execute. When specifying paths they should be relative to the layer path.", + "additionalProperties": false, + "properties": { + "preprovision": { + "title": "pre provision hook", + "description": "Runs before provisioning the layer", + "$ref": "#/definitions/hooks" + }, + "postprovision": { + "title": "post provision hook", + "description": "Runs after provisioning the layer", + "$ref": "#/definitions/hooks" + } + } + } + } + } } - } + }, + "allOf": [ + { + "if": { + "required": [ + "layers" + ], + "properties": { + "layers": { + "type": "array", + "minItems": 1 + } + } + }, + "then": { + "properties": { + "path": false, + "module": false + } + } + } + ] }, "services": { "type": "object",