From 6da5186badfa0f2f07cabdb7b6ab094da8899c15 Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Tue, 24 Mar 2026 16:03:37 -0700 Subject: [PATCH 1/8] add hooks support --- cli/azd/cmd/hooks.go | 40 ++- cli/azd/cmd/hooks_test.go | 154 ++++++++++ cli/azd/cmd/testdata/TestFigSpec.ts | 13 +- .../cmd/testdata/TestUsage-azd-hooks-run.snap | 3 +- cli/azd/cmd/testdata/TestUsage-azd-hooks.snap | 2 +- cli/azd/internal/cmd/provision.go | 82 ++++- cli/azd/pkg/ext/hooks_config.go | 55 ++++ cli/azd/pkg/ext/hooks_config_test.go | 110 +++++++ cli/azd/pkg/infra/provisioning/provider.go | 38 ++- .../pkg/infra/provisioning/provider_test.go | 108 +++++++ cli/azd/pkg/project/project_config.go | 51 +--- cli/azd/test/functional/hooks_test.go | 97 ++++++ .../Test_CLI_Hooks_RegistrationAndRun.yaml | 7 + .../testdata/samples/hooks/azure.yaml | 47 +++ .../testdata/samples/hooks/infra/main.bicep | 5 + .../samples/hooks/infra/main.parameters.json | 9 + .../testdata/samples/hooks/src/app/README.md | 1 + schemas/alpha/azure.yaml.json | 287 ++++++++++++++++-- schemas/v1.0/azure.yaml.json | 75 ++++- 19 files changed, 1088 insertions(+), 96 deletions(-) create mode 100644 cli/azd/cmd/hooks_test.go create mode 100644 cli/azd/pkg/ext/hooks_config.go create mode 100644 cli/azd/pkg/ext/hooks_config_test.go create mode 100644 cli/azd/test/functional/hooks_test.go create mode 100644 cli/azd/test/functional/testdata/recordings/Test_CLI_Hooks_RegistrationAndRun.yaml create mode 100644 cli/azd/test/functional/testdata/samples/hooks/azure.yaml create mode 100644 cli/azd/test/functional/testdata/samples/hooks/infra/main.bicep create mode 100644 cli/azd/test/functional/testdata/samples/hooks/infra/main.parameters.json create mode 100644 cli/azd/test/functional/testdata/samples/hooks/src/app/README.md diff --git a/cli/azd/cmd/hooks.go b/cli/azd/cmd/hooks.go index 1cc582a05f1..b3620da889d 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, infrastructure 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 infrastructure 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" ) @@ -184,6 +187,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 +213,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 +239,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 +273,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)), }) } @@ -330,6 +357,13 @@ func (hra *hooksRunAction) validateAndWarnHooks(ctx context.Context) error { } } + // Add layer hooks + for _, layer := range hra.projectConfig.Infra.Layers { + for hookName, hookConfigs := range layer.Hooks { + allHooks[hookName] = append(allHooks[hookName], hookConfigs...) + } + } + // Create hooks manager and validate hooksManager := ext.NewHooksManager(hra.projectConfig.Path, hra.commandRunner) validationResult := hooksManager.ValidateHooks(ctx, allHooks) diff --git a/cli/azd/cmd/hooks_test.go b/cli/azd/cmd/hooks_test.go new file mode 100644 index 00000000000..05cfcd043c2 --- /dev/null +++ b/cli/azd/cmd/hooks_test.go @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "path/filepath" + "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/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) +} diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 1070af51d1b..7df94dd4d3d 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, infrastructure layers, and services', options: [ { name: ['--environment', '-e'], @@ -2287,6 +2287,15 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--layer'], + description: 'Only runs hooks for the specified infrastructure 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, infrastructure 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..2351c77cd57 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, infrastructure 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 infrastructure 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..c96bd03e300 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, infrastructure 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..d3a199c3fa0 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,60 @@ 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 { + hooks := map[string][]*ext.HookConfig(layer.Hooks) + if len(hooks) == 0 { + return actionFn() + } + + hooksManager := ext.NewHooksManager(layerPath, p.commandRunner) + hooksRunner := ext.NewHooksRunner( + hooksManager, + p.commandRunner, + p.envManager, + p.console, + layerPath, + hooks, + p.env, + p.serviceLocator, + ) + + p.validateAndWarnLayerHooks(ctx, hooksManager, 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/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..78cf3b8c7aa 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 filepath.Clean(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. // @@ -121,7 +136,24 @@ func (o *Options) Validate() error { } anyIncompatibleFieldsSet := func() bool { - return o.Name != "" || o.Module != "" || o.Path != "" || o.DeploymentStacks != nil + return o.Name != "" || o.Module != "" || o.Path != "" || len(o.Hooks) > 0 || o.DeploymentStacks != nil + } + + validateHooks := func(scope string, hooks HooksConfig) error { + for hookName := range hooks { + hookType, eventName := ext.InferHookType(hookName) + if hookType == ext.HookTypeNone || eventName != "provision" { + return errWrap( + fmt.Sprintf("%s: only 'preprovision' and 'postprovision' hooks are supported", scope), + ) + } + } + + return nil + } + + if len(o.Hooks) > 0 { + return errWrap("'hooks' can only be declared under 'infra.layers[]'") } if len(o.Layers) > 0 && anyIncompatibleFieldsSet() { @@ -137,6 +169,10 @@ func (o *Options) Validate() error { if layer.Path == "" { return errWrap(fmt.Sprintf("%s: path must be specified", layer.Name)) } + + if err := validateHooks(layer.Name, layer.Hooks); err != nil { + return err + } } return nil diff --git a/cli/azd/pkg/infra/provisioning/provider_test.go b/cli/azd/pkg/infra/provisioning/provider_test.go index c42f974396c..b25e261e3e6 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,37 @@ func TestOptions_GetWithDefaults(t *testing.T) { } } +func TestOptions_AbsolutePath(t *testing.T) { + projectPath := filepath.Join(string(filepath.Separator), "tmp", "project") + + 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: filepath.Join(string(filepath.Separator), "shared", "infra"), + }, + expected: filepath.Join(string(filepath.Separator), "shared", "infra"), + }, + } + + 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 +385,79 @@ 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.Error(t, err) + require.ErrorContains(t, err, "only 'preprovision' and 'postprovision' hooks are supported") + }) + + 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.Error(t, err) + require.ErrorContains(t, err, "'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.Error(t, err) + require.ErrorContains(t, err, "'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..7fb38bb0eae --- /dev/null +++ b/cli/azd/test/functional/hooks_test.go @@ -0,0 +1,97 @@ +// 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/azure/azure-dev/cli/azd/test/recording" + "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) + + session := recording.Start(t) + + envName := randomOrStoredEnvName(session) + t.Logf("AZURE_ENV_NAME: %s", envName) + + subscriptionId := cfgOrStoredSubscription(session) + require.NotEmpty(t, subscriptionId, "hooks smoke test requires a subscription id") + + location := cfg.Location + if session != nil && session.Playback { + location = "eastus2" + } + require.NotEmpty(t, location, "hooks smoke test requires a location") + + predeployTracePath := filepath.Join(dir, "predeploy-trace.log") + provisionTracePath := filepath.Join(dir, "provision-trace.log") + + cli := azdcli.NewCLI(t, azdcli.WithSession(session)) + cli.WorkingDirectory = dir + baseEnv := append(os.Environ(), + fmt.Sprintf("AZURE_SUBSCRIPTION_ID=%s", subscriptionId), + fmt.Sprintf("AZURE_LOCATION=%s", location), + "AZD_ALPHA_ENABLE_LLM=false", + ) + setHookTraceEnv := func(tracePath string) { + cli.Env = append([]string{}, baseEnv...) + cli.Env = append(cli.Env, fmt.Sprintf("HOOK_TRACE_FILE=%s", tracePath)) + } + + readTraceEntries := func(tracePath string) []string { + traceBytes, err := os.ReadFile(tracePath) + require.NoError(t, err) + + var traceEntries []string + for _, line := range strings.Split(string(traceBytes), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + traceEntries = append(traceEntries, line) + } + + return traceEntries + } + + err := copySample(dir, "hooks") + require.NoError(t, err, "failed expanding sample") + + cli.Env = append([]string{}, baseEnv...) + _, err = cli.RunCommandWithStdIn(ctx, stdinForInit(envName), "init") + require.NoError(t, err) + + setHookTraceEnv(predeployTracePath) + _, err = cli.RunCommand(ctx, "hooks", "run", "predeploy", "--service", "app") + require.NoError(t, err) + + setHookTraceEnv(provisionTracePath) + _, err = cli.RunCommand(ctx, "provision") + require.Error(t, err, "provision should fail for this hooks sample") + + require.Equal(t, []string{ + "command-predeploy", + "service-predeploy", + }, readTraceEntries(predeployTracePath)) + + require.Equal(t, []string{ + "command-preprovision", + "layer-preprovision", + }, readTraceEntries(provisionTracePath)) +} diff --git a/cli/azd/test/functional/testdata/recordings/Test_CLI_Hooks_RegistrationAndRun.yaml b/cli/azd/test/functional/testdata/recordings/Test_CLI_Hooks_RegistrationAndRun.yaml new file mode 100644 index 00000000000..ab9fd854389 --- /dev/null +++ b/cli/azd/test/functional/testdata/recordings/Test_CLI_Hooks_RegistrationAndRun.yaml @@ -0,0 +1,7 @@ +--- +version: 2 +interactions: [] +--- +env_name: azdtest-d5473f6 +subscription_id: faa080af-c1d8-40ad-9cce-e1a450ca5b57 +time: "1774639473" 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..89d9eb663aa --- /dev/null +++ b/cli/azd/test/functional/testdata/samples/hooks/azure.yaml @@ -0,0 +1,47 @@ +name: hooks + +infra: + layers: + - name: core + provider: bicep + 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: src/app + host: containerapp + language: docker + docker: + remoteBuild: true + 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 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/cli/azd/test/functional/testdata/samples/hooks/src/app/README.md b/cli/azd/test/functional/testdata/samples/hooks/src/app/README.md new file mode 100644 index 00000000000..99e0eccb55c --- /dev/null +++ b/cli/azd/test/functional/testdata/samples/hooks/src/app/README.md @@ -0,0 +1 @@ +Placeholder service directory for functional hook tests. diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index d5f2e179393..02c4a8f0285 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -93,9 +93,27 @@ "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)" }, - "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" + } + } + }, + "deploymentStacks": { + "$ref": "#/definitions/deploymentStacksConfig" + } } }, "allOf": [ @@ -137,7 +155,9 @@ }, { "if": { - "required": ["layers"], + "required": [ + "layers" + ], "properties": { "layers": { "type": "array", @@ -325,18 +345,32 @@ "comment": "ContainerApp host - supports image OR project, docker config, and apiVersion", "if": { "properties": { - "host": { "const": "containerapp" } + "host": { + "const": "containerapp" + } } }, "then": { "anyOf": [ { - "required": ["image"], - "not": { "required": ["project"] } + "required": [ + "image" + ], + "not": { + "required": [ + "project" + ] + } }, { - "required": ["project"], - "not": { "required": ["image"] } + "required": [ + "project" + ], + "not": { + "required": [ + "image" + ] + } } ], "properties": { @@ -348,7 +382,9 @@ "comment": "AKS host - project is optional, supports docker and k8s config", "if": { "properties": { - "host": { "const": "aks" } + "host": { + "const": "aks" + } } }, "then": { @@ -363,11 +399,16 @@ "comment": "AI Endpoint host - requires project and config, supports docker", "if": { "properties": { - "host": { "const": "ai.endpoint" } + "host": { + "const": "ai.endpoint" + } } }, "then": { - "required": ["project", "config"], + "required": [ + "project", + "config" + ], "properties": { "config": { "$ref": "#/definitions/aiEndpointConfig", @@ -385,11 +426,15 @@ "comment": "Azure AI Agent host - requires project, supports docker and config", "if": { "properties": { - "host": { "const": "azure.ai.agent" } + "host": { + "const": "azure.ai.agent" + } } }, "then": { - "required": ["project"], + "required": [ + "project" + ], "properties": { "config": { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json", @@ -407,11 +452,20 @@ "comment": "Traditional hosts - require project only, disable container-specific properties", "if": { "properties": { - "host": { "enum": ["appservice", "function", "springapp", "staticwebapp"] } + "host": { + "enum": [ + "appservice", + "function", + "springapp", + "staticwebapp" + ] + } } }, "then": { - "required": ["project"], + "required": [ + "project" + ], "properties": { "image": false, "docker": false, @@ -425,7 +479,11 @@ "comment": "remoteBuild is only valid for function host", "if": { "properties": { - "host": { "not": { "const": "function" } } + "host": { + "not": { + "const": "function" + } + } } }, "then": { @@ -482,20 +540,174 @@ } }, "allOf": [ - { "if": { "properties": { "type": { "const": "host.appservice" } } }, "then": { "$ref": "#/definitions/appServiceResource" } }, - { "if": { "properties": { "type": { "const": "host.containerapp" }}}, "then": { "$ref": "#/definitions/containerAppResource" } }, - { "if": { "properties": { "type": { "const": "ai.openai.model" }}}, "then": { "$ref": "#/definitions/aiModelResource" } }, - { "if": { "properties": { "type": { "const": "ai.project" }}}, "then": { "$ref": "#/definitions/aiProjectResource" } }, - { "if": { "properties": { "type": { "const": "ai.search" }}}, "then": { "$ref": "#/definitions/aiSearchResource" } }, - { "if": { "properties": { "type": { "const": "db.postgres" }}}, "then": { "$ref": "#/definitions/genericDbResource"} }, - { "if": { "properties": { "type": { "const": "db.mysql" }}}, "then": { "$ref": "#/definitions/genericDbResource"} }, - { "if": { "properties": { "type": { "const": "db.redis" }}}, "then": { "$ref": "#/definitions/genericDbResource"} }, - { "if": { "properties": { "type": { "const": "db.mongo" }}}, "then": { "$ref": "#/definitions/genericDbResource"} }, - { "if": { "properties": { "type": { "const": "db.cosmos" }}}, "then": { "$ref": "#/definitions/cosmosDbResource"} }, - { "if": { "properties": { "type": { "const": "messaging.eventhubs" }}}, "then": { "$ref": "#/definitions/eventHubsResource" } }, - { "if": { "properties": { "type": { "const": "messaging.servicebus" }}}, "then": { "$ref": "#/definitions/serviceBusResource" } }, - { "if": { "properties": { "type": { "const": "storage" }}}, "then": { "$ref": "#/definitions/storageAccountResource"} }, - { "if": { "properties": { "type": { "const": "keyvault" }}}, "then": { "$ref": "#/definitions/keyVaultResource"} } + { + "if": { + "properties": { + "type": { + "const": "host.appservice" + } + } + }, + "then": { + "$ref": "#/definitions/appServiceResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "host.containerapp" + } + } + }, + "then": { + "$ref": "#/definitions/containerAppResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "ai.openai.model" + } + } + }, + "then": { + "$ref": "#/definitions/aiModelResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "ai.project" + } + } + }, + "then": { + "$ref": "#/definitions/aiProjectResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "ai.search" + } + } + }, + "then": { + "$ref": "#/definitions/aiSearchResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "db.postgres" + } + } + }, + "then": { + "$ref": "#/definitions/genericDbResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "db.mysql" + } + } + }, + "then": { + "$ref": "#/definitions/genericDbResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "db.redis" + } + } + }, + "then": { + "$ref": "#/definitions/genericDbResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "db.mongo" + } + } + }, + "then": { + "$ref": "#/definitions/genericDbResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "db.cosmos" + } + } + }, + "then": { + "$ref": "#/definitions/cosmosDbResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "messaging.eventhubs" + } + } + }, + "then": { + "$ref": "#/definitions/eventHubsResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "messaging.servicebus" + } + } + }, + "then": { + "$ref": "#/definitions/serviceBusResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "storage" + } + } + }, + "then": { + "$ref": "#/definitions/storageAccountResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "keyvault" + } + } + }, + "then": { + "$ref": "#/definitions/keyVaultResource" + } + } ] } }, @@ -1626,7 +1838,12 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["name", "version", "format", "sku"], + "required": [ + "name", + "version", + "format", + "sku" + ], "properties": { "name": { "type": "string", @@ -1648,7 +1865,11 @@ "title": "The SKU configuration for the AI model.", "description": "Required. The SKU details for the AI model.", "additionalProperties": false, - "required": ["name", "usageName", "capacity"], + "required": [ + "name", + "usageName", + "capacity" + ], "properties": { "name": { "type": "string", diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index ccc1e3a877a..607fa4349aa 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -59,8 +59,81 @@ "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": false, + "layers": { + "type": "array", + "title": "Provisioning layers.", + "description": "Optional. Layers for Azure infrastructure provisioning.", + "additionalProperties": false, + "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", From fa30529d8458816aa95734e5d88ca7a693b4f34f Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Fri, 27 Mar 2026 12:41:21 -0700 Subject: [PATCH 2/8] test: refine layer hooks functional smoke tests Switch the hooks sample to a lightweight App Service app, verify deploy via command and prerestore hooks, and keep `azd hooks run` coverage in local-only subtests so the smoke stays fast. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/test/functional/hooks_test.go | 152 +++++++++++++----- .../testdata/samples/hooks/azure.yaml | 15 +- .../testdata/samples/hooks/src/app/README.md | 1 - 3 files changed, 125 insertions(+), 43 deletions(-) delete mode 100644 cli/azd/test/functional/testdata/samples/hooks/src/app/README.md diff --git a/cli/azd/test/functional/hooks_test.go b/cli/azd/test/functional/hooks_test.go index 7fb38bb0eae..df5b93cc6d5 100644 --- a/cli/azd/test/functional/hooks_test.go +++ b/cli/azd/test/functional/hooks_test.go @@ -33,43 +33,113 @@ func Test_CLI_Hooks_RegistrationAndRun(t *testing.T) { require.NotEmpty(t, subscriptionId, "hooks smoke test requires a subscription id") location := cfg.Location - if session != nil && session.Playback { - location = "eastus2" - } require.NotEmpty(t, location, "hooks smoke test requires a location") - predeployTracePath := filepath.Join(dir, "predeploy-trace.log") + deployTracePath := filepath.Join(dir, "deploy-trace.log") provisionTracePath := filepath.Join(dir, "provision-trace.log") cli := azdcli.NewCLI(t, azdcli.WithSession(session)) cli.WorkingDirectory = dir - baseEnv := append(os.Environ(), - fmt.Sprintf("AZURE_SUBSCRIPTION_ID=%s", subscriptionId), - fmt.Sprintf("AZURE_LOCATION=%s", location), - "AZD_ALPHA_ENABLE_LLM=false", - ) - setHookTraceEnv := func(tracePath string) { - cli.Env = append([]string{}, baseEnv...) - cli.Env = append(cli.Env, fmt.Sprintf("HOOK_TRACE_FILE=%s", tracePath)) - } + baseEnv := hooksTestEnv(subscriptionId, location) + cli.Env = append(os.Environ(), baseEnv...) + + err := copySample(dir, "hooks") + require.NoError(t, err, "failed expanding sample") + + _, err = cli.RunCommandWithStdIn(ctx, stdinForInit(envName), "init") + require.NoError(t, err) + + setHookTraceEnv(cli, baseEnv, deployTracePath) + _, err = cli.RunCommand(ctx, "deploy") + require.Error(t, err, "deploy should fail for this hooks sample") + + setHookTraceEnv(cli, baseEnv, provisionTracePath) + _, 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() - readTraceEntries := func(tracePath string) []string { - traceBytes, err := os.ReadFile(tracePath) + t.Run("RunAll", func(t *testing.T) { + traceEntries, err := runLocalHooksCommand(t, "predeploy") require.NoError(t, err) - var traceEntries []string - for _, line := range strings.Split(string(traceBytes), "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } + 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) + }) + }) +} - traceEntries = append(traceEntries, line) - } +func hooksTestEnv(subscriptionId string, location string) []string { + baseEnv := append(os.Environ(), "AZD_ALPHA_ENABLE_LLM=false") + if subscriptionId != "" { + baseEnv = append(baseEnv, fmt.Sprintf("AZURE_SUBSCRIPTION_ID=%s", subscriptionId)) + } - return traceEntries + if location != "" { + baseEnv = append(baseEnv, fmt.Sprintf("AZURE_LOCATION=%s", location)) } + return baseEnv +} + +func setHookTraceEnv(cli *azdcli.CLI, baseEnv []string, tracePath string) { + cli.Env = append([]string{}, baseEnv...) + cli.Env = append(cli.Env, fmt.Sprintf("HOOK_TRACE_FILE=%s", tracePath)) +} + +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 := randomOrStoredEnvName(nil) + t.Logf("AZURE_ENV_NAME: %s", envName) + + cli := azdcli.NewCLI(t) + cli.WorkingDirectory = dir + + baseEnv := hooksTestEnv("", "") + tracePath := filepath.Join(dir, "hooks-run-trace.log") + err := copySample(dir, "hooks") require.NoError(t, err, "failed expanding sample") @@ -77,21 +147,29 @@ func Test_CLI_Hooks_RegistrationAndRun(t *testing.T) { _, err = cli.RunCommandWithStdIn(ctx, stdinForInit(envName), "init") require.NoError(t, err) - setHookTraceEnv(predeployTracePath) - _, err = cli.RunCommand(ctx, "hooks", "run", "predeploy", "--service", "app") + setHookTraceEnv(cli, baseEnv, 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) - setHookTraceEnv(provisionTracePath) - _, err = cli.RunCommand(ctx, "provision") - require.Error(t, err, "provision should fail for this hooks sample") + var traceEntries []string + for line := range strings.SplitSeq(string(traceBytes), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } - require.Equal(t, []string{ - "command-predeploy", - "service-predeploy", - }, readTraceEntries(predeployTracePath)) + traceEntries = append(traceEntries, line) + } - require.Equal(t, []string{ - "command-preprovision", - "layer-preprovision", - }, readTraceEntries(provisionTracePath)) + return traceEntries } diff --git a/cli/azd/test/functional/testdata/samples/hooks/azure.yaml b/cli/azd/test/functional/testdata/samples/hooks/azure.yaml index 89d9eb663aa..cde950d29ca 100644 --- a/cli/azd/test/functional/testdata/samples/hooks/azure.yaml +++ b/cli/azd/test/functional/testdata/samples/hooks/azure.yaml @@ -32,11 +32,9 @@ hooks: services: app: - project: src/app - host: containerapp - language: docker - docker: - remoteBuild: true + project: . + host: appservice + language: js hooks: predeploy: windows: @@ -45,3 +43,10 @@ services: 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/src/app/README.md b/cli/azd/test/functional/testdata/samples/hooks/src/app/README.md deleted file mode 100644 index 99e0eccb55c..00000000000 --- a/cli/azd/test/functional/testdata/samples/hooks/src/app/README.md +++ /dev/null @@ -1 +0,0 @@ -Placeholder service directory for functional hook tests. From 5ad17db9d0073dcd8a5e429a2b0e7eff29f97062 Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Mon, 30 Mar 2026 10:31:10 -0700 Subject: [PATCH 3/8] update schema --- cli/azd/cmd/hooks.go | 4 +- cli/azd/cmd/testdata/TestFigSpec.ts | 6 +- .../cmd/testdata/TestUsage-azd-hooks-run.snap | 4 +- cli/azd/cmd/testdata/TestUsage-azd-hooks.snap | 2 +- .../testdata/samples/funcapp copy/up.log | 14 - .../testdata/samples/hooks/azure.yaml | 3 +- schemas/alpha/azure.yaml.json | 269 +++--------------- schemas/v1.0/azure.yaml.json | 1 - 8 files changed, 43 insertions(+), 260 deletions(-) delete mode 100644 cli/azd/test/functional/testdata/samples/funcapp copy/up.log diff --git a/cli/azd/cmd/hooks.go b/cli/azd/cmd/hooks.go index b3620da889d..3282d30f450 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, infrastructure layers, and services", + Short: "Runs the specified hook for the project, provisioning layers, and services", Args: cobra.ExactArgs(1), } } @@ -71,7 +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 infrastructure layer.") + 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.") } diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 7df94dd4d3d..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, infrastructure layers, and services', + description: 'Runs the specified hook for the project, provisioning layers, and services', options: [ { name: ['--environment', '-e'], @@ -2289,7 +2289,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--layer'], - description: 'Only runs hooks for the specified infrastructure layer.', + description: 'Only runs hooks for the specified provisioning layer.', args: [ { name: 'layer', @@ -3552,7 +3552,7 @@ const completionSpec: Fig.Spec = { subcommands: [ { name: ['run'], - description: 'Runs the specified hook for the project, infrastructure layers, 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 2351c77cd57..e51a58ac200 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-hooks-run.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-hooks-run.snap @@ -1,12 +1,12 @@ -Runs the specified hook for the project, infrastructure layers, 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 infrastructure layer. + --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 c96bd03e300..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, infrastructure layers, 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/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 index cde950d29ca..cc0ba925ae5 100644 --- a/cli/azd/test/functional/testdata/samples/hooks/azure.yaml +++ b/cli/azd/test/functional/testdata/samples/hooks/azure.yaml @@ -1,9 +1,10 @@ +# yaml-language-server: $schema=/Users/weilim/repos/azd2/schemas/v1.0/azure.yaml.json + name: hooks infra: layers: - name: core - provider: bicep path: infra hooks: preprovision: diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index 02c4a8f0285..95ed08ad755 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -93,6 +93,9 @@ "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)" }, + "deploymentStacks": { + "$ref": "#/definitions/deploymentStacksConfig" + }, "hooks": { "type": "object", "title": "Provisioning layer hooks", @@ -110,9 +113,6 @@ "$ref": "#/definitions/hooks" } } - }, - "deploymentStacks": { - "$ref": "#/definitions/deploymentStacksConfig" } } }, @@ -155,9 +155,7 @@ }, { "if": { - "required": [ - "layers" - ], + "required": ["layers"], "properties": { "layers": { "type": "array", @@ -345,32 +343,18 @@ "comment": "ContainerApp host - supports image OR project, docker config, and apiVersion", "if": { "properties": { - "host": { - "const": "containerapp" - } + "host": { "const": "containerapp" } } }, "then": { "anyOf": [ { - "required": [ - "image" - ], - "not": { - "required": [ - "project" - ] - } + "required": ["image"], + "not": { "required": ["project"] } }, { - "required": [ - "project" - ], - "not": { - "required": [ - "image" - ] - } + "required": ["project"], + "not": { "required": ["image"] } } ], "properties": { @@ -382,9 +366,7 @@ "comment": "AKS host - project is optional, supports docker and k8s config", "if": { "properties": { - "host": { - "const": "aks" - } + "host": { "const": "aks" } } }, "then": { @@ -399,16 +381,11 @@ "comment": "AI Endpoint host - requires project and config, supports docker", "if": { "properties": { - "host": { - "const": "ai.endpoint" - } + "host": { "const": "ai.endpoint" } } }, "then": { - "required": [ - "project", - "config" - ], + "required": ["project", "config"], "properties": { "config": { "$ref": "#/definitions/aiEndpointConfig", @@ -426,15 +403,11 @@ "comment": "Azure AI Agent host - requires project, supports docker and config", "if": { "properties": { - "host": { - "const": "azure.ai.agent" - } + "host": { "const": "azure.ai.agent" } } }, "then": { - "required": [ - "project" - ], + "required": ["project"], "properties": { "config": { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json", @@ -452,20 +425,11 @@ "comment": "Traditional hosts - require project only, disable container-specific properties", "if": { "properties": { - "host": { - "enum": [ - "appservice", - "function", - "springapp", - "staticwebapp" - ] - } + "host": { "enum": ["appservice", "function", "springapp", "staticwebapp"] } } }, "then": { - "required": [ - "project" - ], + "required": ["project"], "properties": { "image": false, "docker": false, @@ -479,11 +443,7 @@ "comment": "remoteBuild is only valid for function host", "if": { "properties": { - "host": { - "not": { - "const": "function" - } - } + "host": { "not": { "const": "function" } } } }, "then": { @@ -540,174 +500,20 @@ } }, "allOf": [ - { - "if": { - "properties": { - "type": { - "const": "host.appservice" - } - } - }, - "then": { - "$ref": "#/definitions/appServiceResource" - } - }, - { - "if": { - "properties": { - "type": { - "const": "host.containerapp" - } - } - }, - "then": { - "$ref": "#/definitions/containerAppResource" - } - }, - { - "if": { - "properties": { - "type": { - "const": "ai.openai.model" - } - } - }, - "then": { - "$ref": "#/definitions/aiModelResource" - } - }, - { - "if": { - "properties": { - "type": { - "const": "ai.project" - } - } - }, - "then": { - "$ref": "#/definitions/aiProjectResource" - } - }, - { - "if": { - "properties": { - "type": { - "const": "ai.search" - } - } - }, - "then": { - "$ref": "#/definitions/aiSearchResource" - } - }, - { - "if": { - "properties": { - "type": { - "const": "db.postgres" - } - } - }, - "then": { - "$ref": "#/definitions/genericDbResource" - } - }, - { - "if": { - "properties": { - "type": { - "const": "db.mysql" - } - } - }, - "then": { - "$ref": "#/definitions/genericDbResource" - } - }, - { - "if": { - "properties": { - "type": { - "const": "db.redis" - } - } - }, - "then": { - "$ref": "#/definitions/genericDbResource" - } - }, - { - "if": { - "properties": { - "type": { - "const": "db.mongo" - } - } - }, - "then": { - "$ref": "#/definitions/genericDbResource" - } - }, - { - "if": { - "properties": { - "type": { - "const": "db.cosmos" - } - } - }, - "then": { - "$ref": "#/definitions/cosmosDbResource" - } - }, - { - "if": { - "properties": { - "type": { - "const": "messaging.eventhubs" - } - } - }, - "then": { - "$ref": "#/definitions/eventHubsResource" - } - }, - { - "if": { - "properties": { - "type": { - "const": "messaging.servicebus" - } - } - }, - "then": { - "$ref": "#/definitions/serviceBusResource" - } - }, - { - "if": { - "properties": { - "type": { - "const": "storage" - } - } - }, - "then": { - "$ref": "#/definitions/storageAccountResource" - } - }, - { - "if": { - "properties": { - "type": { - "const": "keyvault" - } - } - }, - "then": { - "$ref": "#/definitions/keyVaultResource" - } - } + { "if": { "properties": { "type": { "const": "host.appservice" } } }, "then": { "$ref": "#/definitions/appServiceResource" } }, + { "if": { "properties": { "type": { "const": "host.containerapp" }}}, "then": { "$ref": "#/definitions/containerAppResource" } }, + { "if": { "properties": { "type": { "const": "ai.openai.model" }}}, "then": { "$ref": "#/definitions/aiModelResource" } }, + { "if": { "properties": { "type": { "const": "ai.project" }}}, "then": { "$ref": "#/definitions/aiProjectResource" } }, + { "if": { "properties": { "type": { "const": "ai.search" }}}, "then": { "$ref": "#/definitions/aiSearchResource" } }, + { "if": { "properties": { "type": { "const": "db.postgres" }}}, "then": { "$ref": "#/definitions/genericDbResource"} }, + { "if": { "properties": { "type": { "const": "db.mysql" }}}, "then": { "$ref": "#/definitions/genericDbResource"} }, + { "if": { "properties": { "type": { "const": "db.redis" }}}, "then": { "$ref": "#/definitions/genericDbResource"} }, + { "if": { "properties": { "type": { "const": "db.mongo" }}}, "then": { "$ref": "#/definitions/genericDbResource"} }, + { "if": { "properties": { "type": { "const": "db.cosmos" }}}, "then": { "$ref": "#/definitions/cosmosDbResource"} }, + { "if": { "properties": { "type": { "const": "messaging.eventhubs" }}}, "then": { "$ref": "#/definitions/eventHubsResource" } }, + { "if": { "properties": { "type": { "const": "messaging.servicebus" }}}, "then": { "$ref": "#/definitions/serviceBusResource" } }, + { "if": { "properties": { "type": { "const": "storage" }}}, "then": { "$ref": "#/definitions/storageAccountResource"} }, + { "if": { "properties": { "type": { "const": "keyvault" }}}, "then": { "$ref": "#/definitions/keyVaultResource"} } ] } }, @@ -1838,12 +1644,7 @@ "items": { "type": "object", "additionalProperties": false, - "required": [ - "name", - "version", - "format", - "sku" - ], + "required": ["name", "version", "format", "sku"], "properties": { "name": { "type": "string", @@ -1865,11 +1666,7 @@ "title": "The SKU configuration for the AI model.", "description": "Required. The SKU details for the AI model.", "additionalProperties": false, - "required": [ - "name", - "usageName", - "capacity" - ], + "required": ["name", "usageName", "capacity"], "properties": { "name": { "type": "string", diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index 607fa4349aa..dab7e4e273f 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -60,7 +60,6 @@ "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": false, "layers": { "type": "array", "title": "Provisioning layers.", From 322f1c9c8283934c166b9e2c815c64d9b257cf42 Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Mon, 30 Mar 2026 11:25:55 -0700 Subject: [PATCH 4/8] simplify tests --- cli/azd/test/functional/hooks_test.go | 51 +++++-------------- .../Test_CLI_Hooks_RegistrationAndRun.yaml | 7 --- 2 files changed, 13 insertions(+), 45 deletions(-) delete mode 100644 cli/azd/test/functional/testdata/recordings/Test_CLI_Hooks_RegistrationAndRun.yaml diff --git a/cli/azd/test/functional/hooks_test.go b/cli/azd/test/functional/hooks_test.go index df5b93cc6d5..738b20c7b6f 100644 --- a/cli/azd/test/functional/hooks_test.go +++ b/cli/azd/test/functional/hooks_test.go @@ -11,7 +11,6 @@ import ( "testing" "github.com/azure/azure-dev/cli/azd/test/azdcli" - "github.com/azure/azure-dev/cli/azd/test/recording" "github.com/stretchr/testify/require" ) @@ -24,24 +23,17 @@ func Test_CLI_Hooks_RegistrationAndRun(t *testing.T) { dir := tempDirWithDiagnostics(t) t.Logf("DIR: %s", dir) - session := recording.Start(t) - - envName := randomOrStoredEnvName(session) + envName := randomEnvName() t.Logf("AZURE_ENV_NAME: %s", envName) - subscriptionId := cfgOrStoredSubscription(session) - require.NotEmpty(t, subscriptionId, "hooks smoke test requires a subscription id") - - location := cfg.Location - require.NotEmpty(t, location, "hooks smoke test requires a location") - deployTracePath := filepath.Join(dir, "deploy-trace.log") provisionTracePath := filepath.Join(dir, "provision-trace.log") - cli := azdcli.NewCLI(t, azdcli.WithSession(session)) + cli := azdcli.NewCLI(t) cli.WorkingDirectory = dir - baseEnv := hooksTestEnv(subscriptionId, location) - cli.Env = append(os.Environ(), baseEnv...) + 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") @@ -49,11 +41,13 @@ func Test_CLI_Hooks_RegistrationAndRun(t *testing.T) { _, err = cli.RunCommandWithStdIn(ctx, stdinForInit(envName), "init") require.NoError(t, err) - setHookTraceEnv(cli, baseEnv, deployTracePath) + 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") - setHookTraceEnv(cli, baseEnv, provisionTracePath) + 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") @@ -104,24 +98,6 @@ func Test_CLI_Hooks_Run_RegistrationAndRun(t *testing.T) { }) } -func hooksTestEnv(subscriptionId string, location string) []string { - baseEnv := append(os.Environ(), "AZD_ALPHA_ENABLE_LLM=false") - if subscriptionId != "" { - baseEnv = append(baseEnv, fmt.Sprintf("AZURE_SUBSCRIPTION_ID=%s", subscriptionId)) - } - - if location != "" { - baseEnv = append(baseEnv, fmt.Sprintf("AZURE_LOCATION=%s", location)) - } - - return baseEnv -} - -func setHookTraceEnv(cli *azdcli.CLI, baseEnv []string, tracePath string) { - cli.Env = append([]string{}, baseEnv...) - cli.Env = append(cli.Env, fmt.Sprintf("HOOK_TRACE_FILE=%s", tracePath)) -} - func runLocalHooksCommand(t *testing.T, args ...string) ([]string, error) { t.Helper() @@ -131,24 +107,23 @@ func runLocalHooksCommand(t *testing.T, args ...string) ([]string, error) { dir := tempDirWithDiagnostics(t) t.Logf("DIR: %s", dir) - envName := randomOrStoredEnvName(nil) + envName := randomEnvName() t.Logf("AZURE_ENV_NAME: %s", envName) cli := azdcli.NewCLI(t) cli.WorkingDirectory = dir + cli.Env = append(cli.Env, os.Environ()...) - baseEnv := hooksTestEnv("", "") 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") - cli.Env = append([]string{}, baseEnv...) _, err = cli.RunCommandWithStdIn(ctx, stdinForInit(envName), "init") require.NoError(t, err) - setHookTraceEnv(cli, baseEnv, tracePath) - + cli.Env = append(cli.Env, fmt.Sprintf("HOOK_TRACE_FILE=%s", tracePath)) command := append([]string{"hooks", "run"}, args...) _, err = cli.RunCommand(ctx, command...) diff --git a/cli/azd/test/functional/testdata/recordings/Test_CLI_Hooks_RegistrationAndRun.yaml b/cli/azd/test/functional/testdata/recordings/Test_CLI_Hooks_RegistrationAndRun.yaml deleted file mode 100644 index ab9fd854389..00000000000 --- a/cli/azd/test/functional/testdata/recordings/Test_CLI_Hooks_RegistrationAndRun.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -version: 2 -interactions: [] ---- -env_name: azdtest-d5473f6 -subscription_id: faa080af-c1d8-40ad-9cce-e1a450ca5b57 -time: "1774639473" From 252597588b7805419f37f950be34de9808a5547b Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Mon, 30 Mar 2026 13:20:11 -0700 Subject: [PATCH 5/8] fix Clean() on abs path --- cli/azd/pkg/infra/provisioning/provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/pkg/infra/provisioning/provider.go b/cli/azd/pkg/infra/provisioning/provider.go index 78cf3b8c7aa..c6a8d4f6276 100644 --- a/cli/azd/pkg/infra/provisioning/provider.go +++ b/cli/azd/pkg/infra/provisioning/provider.go @@ -84,7 +84,7 @@ func (o Options) GetWithDefaults(other ...Options) (Options, error) { // 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 filepath.Clean(o.Path) + return o.Path } return filepath.Join(projectPath, o.Path) From 2ee8f02c2ac4e08dca9b670ac73a0b78a025d991 Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Mon, 30 Mar 2026 14:08:37 -0700 Subject: [PATCH 6/8] fix test on Windows --- cli/azd/pkg/infra/provisioning/provider_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cli/azd/pkg/infra/provisioning/provider_test.go b/cli/azd/pkg/infra/provisioning/provider_test.go index b25e261e3e6..8c1d5c73c57 100644 --- a/cli/azd/pkg/infra/provisioning/provider_test.go +++ b/cli/azd/pkg/infra/provisioning/provider_test.go @@ -264,7 +264,9 @@ func TestOptions_GetWithDefaults(t *testing.T) { } func TestOptions_AbsolutePath(t *testing.T) { - projectPath := filepath.Join(string(filepath.Separator), "tmp", "project") + rootPath := t.TempDir() + projectPath := filepath.Join(rootPath, "project") + absoluteLayerPath := filepath.Join(rootPath, "shared", "infra") tests := []struct { name string @@ -281,9 +283,9 @@ func TestOptions_AbsolutePath(t *testing.T) { { name: "keeps absolute path", options: Options{ - Path: filepath.Join(string(filepath.Separator), "shared", "infra"), + Path: absoluteLayerPath, }, - expected: filepath.Join(string(filepath.Separator), "shared", "infra"), + expected: absoluteLayerPath, }, } From fdd940492f64962504c5dff1e9a7cd9736412055 Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Mon, 30 Mar 2026 15:46:29 -0700 Subject: [PATCH 7/8] 2nd round fixes --- cli/azd/cmd/hooks.go | 14 +++- cli/azd/cmd/hooks_test.go | 75 +++++++++++++++++++ cli/azd/internal/tracing/fields/fields.go | 2 +- cli/azd/pkg/infra/provisioning/provider.go | 49 ++++++++---- .../pkg/infra/provisioning/provider_test.go | 18 +++++ .../testdata/samples/hooks/azure.yaml | 2 - schemas/alpha/azure.yaml.json | 1 - schemas/v1.0/azure.yaml.json | 1 - 8 files changed, 140 insertions(+), 22 deletions(-) diff --git a/cli/azd/cmd/hooks.go b/cli/azd/cmd/hooks.go index 3282d30f450..71fd054726d 100644 --- a/cli/azd/cmd/hooks.go +++ b/cli/azd/cmd/hooks.go @@ -145,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" } diff --git a/cli/azd/cmd/hooks_test.go b/cli/azd/cmd/hooks_test.go index 05cfcd043c2..a5e78e898a0 100644 --- a/cli/azd/cmd/hooks_test.go +++ b/cli/azd/cmd/hooks_test.go @@ -8,6 +8,8 @@ import ( "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" @@ -152,3 +154,76 @@ func Test_HooksRunAction_FiltersLayerHooks(t *testing.T) { 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") +} 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/infra/provisioning/provider.go b/cli/azd/pkg/infra/provisioning/provider.go index c6a8d4f6276..14468bc6743 100644 --- a/cli/azd/pkg/infra/provisioning/provider.go +++ b/cli/azd/pkg/infra/provisioning/provider.go @@ -5,6 +5,7 @@ package provisioning import ( "context" + "errors" "fmt" "path/filepath" "strings" @@ -131,41 +132,57 @@ 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 errors.New("'hooks' can only be declared under 'infra.layers[]'") } - anyIncompatibleFieldsSet := func() bool { - return o.Name != "" || o.Module != "" || o.Path != "" || len(o.Hooks) > 0 || o.DeploymentStacks != nil + if len(o.Layers) > 0 { + anyIncompatibleFieldsSet := func() bool { + return o.Name != "" || o.Module != "" || o.Path != "" || len(o.Hooks) > 0 || o.DeploymentStacks != nil + } + + if anyIncompatibleFieldsSet() { + return errors.New( + "properties on 'infra' cannot be declared when 'infra.layers' is declared") + } + + if err := o.validateLayers(); err != nil { + return err + } + } + + return nil +} + +func (o *Options) validateLayers() error { + errWrap := func(err string) error { + return fmt.Errorf("validating infra.layers: %s", err) } validateHooks := func(scope string, hooks HooksConfig) error { for hookName := range hooks { hookType, eventName := ext.InferHookType(hookName) if hookType == ext.HookTypeNone || eventName != "provision" { - return errWrap( - fmt.Sprintf("%s: only 'preprovision' and 'postprovision' hooks are supported", scope), - ) + return fmt.Errorf("%s: only 'preprovision' and 'postprovision' hooks are supported", scope) } } return nil } - if len(o.Hooks) > 0 { - return errWrap("'hooks' can only be declared under 'infra.layers[]'") - } - - if len(o.Layers) > 0 && anyIncompatibleFieldsSet() { - return errWrap( - "properties on 'infra' cannot be declared when 'infra.layers' is declared") - } - + seenLayers := map[string]struct{}{} for _, layer := range o.Layers { if layer.Name == "" { return errWrap("name must be specified for each provisioning layer") } + if _, has := seenLayers[layer.Name]; has { + return errWrap(fmt.Sprintf("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)) } diff --git a/cli/azd/pkg/infra/provisioning/provider_test.go b/cli/azd/pkg/infra/provisioning/provider_test.go index 8c1d5c73c57..c482c04d0d8 100644 --- a/cli/azd/pkg/infra/provisioning/provider_test.go +++ b/cli/azd/pkg/infra/provisioning/provider_test.go @@ -429,6 +429,24 @@ func TestOptions_Validate_Hooks(t *testing.T) { require.ErrorContains(t, err, "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", diff --git a/cli/azd/test/functional/testdata/samples/hooks/azure.yaml b/cli/azd/test/functional/testdata/samples/hooks/azure.yaml index cc0ba925ae5..e84389bdc1b 100644 --- a/cli/azd/test/functional/testdata/samples/hooks/azure.yaml +++ b/cli/azd/test/functional/testdata/samples/hooks/azure.yaml @@ -1,5 +1,3 @@ -# yaml-language-server: $schema=/Users/weilim/repos/azd2/schemas/v1.0/azure.yaml.json - name: hooks infra: diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index 95ed08ad755..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", diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index dab7e4e273f..bfdf11f9535 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -64,7 +64,6 @@ "type": "array", "title": "Provisioning layers.", "description": "Optional. Layers for Azure infrastructure provisioning.", - "additionalProperties": false, "uniqueItems": true, "items": { "type": "object", From e8a1d72f6f338ef1b6764259019d40aca2d809fc Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Tue, 31 Mar 2026 12:14:02 -0700 Subject: [PATCH 8/8] Fix hook validation cwd handling and provider validation errors Validate hooks using scope-specific working directories so relative file hooks are resolved correctly for services and layers, remove the redundant layer hook conversion during provision, and clean up provisioning validation error wrapping so root and layer errors are wrapped consistently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/hooks.go | 55 +++++++------ cli/azd/cmd/hooks_test.go | 47 +++++++++++ cli/azd/cmd/middleware/hooks.go | 60 +++++++------- cli/azd/cmd/middleware/hooks_test.go | 78 +++++++++++++++++++ cli/azd/internal/cmd/provision.go | 7 +- cli/azd/pkg/infra/provisioning/provider.go | 31 ++++---- .../pkg/infra/provisioning/provider_test.go | 13 ++-- 7 files changed, 208 insertions(+), 83 deletions(-) diff --git a/cli/azd/cmd/hooks.go b/cli/azd/cmd/hooks.go index 71fd054726d..6f4cce15ae4 100644 --- a/cli/azd/cmd/hooks.go +++ b/cli/azd/cmd/hooks.go @@ -351,44 +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 + } + + 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 + } - // Add project hooks - for hookName, hookConfigs := range hra.projectConfig.Hooks { - allHooks[hookName] = append(allHooks[hookName], hookConfigs...) + 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) } } - // Add layer hooks for _, layer := range hra.projectConfig.Infra.Layers { - for hookName, hookConfigs := range layer.Hooks { - allHooks[hookName] = append(allHooks[hookName], hookConfigs...) - } - } - - // 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, "") + 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 index a5e78e898a0..5307c5cd80e 100644 --- a/cli/azd/cmd/hooks_test.go +++ b/cli/azd/cmd/hooks_test.go @@ -5,6 +5,7 @@ package cmd import ( "context" + "os" "path/filepath" "testing" @@ -227,3 +228,49 @@ func Test_HooksRunAction_RejectsServiceAndLayerTogether(t *testing.T) { 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/internal/cmd/provision.go b/cli/azd/internal/cmd/provision.go index d3a199c3fa0..cf76b0a0f62 100644 --- a/cli/azd/internal/cmd/provision.go +++ b/cli/azd/internal/cmd/provision.go @@ -531,8 +531,7 @@ func (p *ProvisionAction) runLayerProvisionWithHooks( layerPath string, actionFn ext.InvokeFn, ) error { - hooks := map[string][]*ext.HookConfig(layer.Hooks) - if len(hooks) == 0 { + if len(layer.Hooks) == 0 { return actionFn() } @@ -543,12 +542,12 @@ func (p *ProvisionAction) runLayerProvisionWithHooks( p.envManager, p.console, layerPath, - hooks, + layer.Hooks, p.env, p.serviceLocator, ) - p.validateAndWarnLayerHooks(ctx, hooksManager, hooks) + p.validateAndWarnLayerHooks(ctx, hooksManager, layer.Hooks) if err := hooksRunner.Invoke(ctx, []string{string(project.ProjectEventProvision)}, actionFn); err != nil { if layer.Name == "" { diff --git a/cli/azd/pkg/infra/provisioning/provider.go b/cli/azd/pkg/infra/provisioning/provider.go index 14468bc6743..bc46e77c1f1 100644 --- a/cli/azd/pkg/infra/provisioning/provider.go +++ b/cli/azd/pkg/infra/provisioning/provider.go @@ -5,7 +5,6 @@ package provisioning import ( "context" - "errors" "fmt" "path/filepath" "strings" @@ -132,34 +131,40 @@ 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 { - if len(o.Hooks) > 0 { - return errors.New("'hooks' can only be declared under 'infra.layers[]'") + return validateErr("infra", "'hooks' can only be declared under 'infra.layers[]'") } if len(o.Layers) > 0 { anyIncompatibleFieldsSet := func() bool { - return o.Name != "" || o.Module != "" || o.Path != "" || len(o.Hooks) > 0 || o.DeploymentStacks != nil + return o.Name != "" || o.Module != "" || o.Path != "" || o.DeploymentStacks != nil } if anyIncompatibleFieldsSet() { - return errors.New( - "properties on 'infra' cannot be declared when 'infra.layers' is declared") + return validateErr("infra", "properties on 'infra' cannot be declared when 'infra.layers' is declared") } if err := o.validateLayers(); err != nil { - return err + return wrapValidateErr("infra.layers", err) } } return nil } -func (o *Options) validateLayers() error { - errWrap := func(err string) error { - return fmt.Errorf("validating infra.layers: %s", err) +func wrapValidateErr(scope string, err error) error { + if err == nil { + return nil } + 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) @@ -174,17 +179,17 @@ func (o *Options) validateLayers() error { 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 errWrap(fmt.Sprintf("duplicate layer name '%s' is not allowed", layer.Name)) + 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 { diff --git a/cli/azd/pkg/infra/provisioning/provider_test.go b/cli/azd/pkg/infra/provisioning/provider_test.go index c482c04d0d8..3ec26764390 100644 --- a/cli/azd/pkg/infra/provisioning/provider_test.go +++ b/cli/azd/pkg/infra/provisioning/provider_test.go @@ -425,8 +425,11 @@ func TestOptions_Validate_Hooks(t *testing.T) { }, }).Validate() - require.Error(t, err) - require.ErrorContains(t, err, "only 'preprovision' and 'postprovision' hooks are supported") + 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) { @@ -463,8 +466,7 @@ func TestOptions_Validate_Hooks(t *testing.T) { }, }).Validate() - require.Error(t, err) - require.ErrorContains(t, err, "'hooks' can only be declared under 'infra.layers[]'") + 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) { @@ -477,7 +479,6 @@ func TestOptions_Validate_Hooks(t *testing.T) { }, }).Validate() - require.Error(t, err) - require.ErrorContains(t, err, "'hooks' can only be declared under 'infra.layers[]'") + require.EqualError(t, err, "validating infra: 'hooks' can only be declared under 'infra.layers[]'") }) }