diff --git a/cli/azd/pkg/project/project.go b/cli/azd/pkg/project/project.go index df98bd8e755..ab2b66fd37a 100644 --- a/cli/azd/pkg/project/project.go +++ b/cli/azd/pkg/project/project.go @@ -77,6 +77,10 @@ func Parse(ctx context.Context, yamlContent string) (*ProjectConfig, error) { projectConfig.Infra.Path = "infra" } + if projectConfig.Infra.Module == "" { + projectConfig.Infra.Module = DefaultModule + } + if strings.Contains(projectConfig.Infra.Path, "\\") && !strings.Contains(projectConfig.Infra.Path, "/") { projectConfig.Infra.Path = strings.ReplaceAll(projectConfig.Infra.Path, "\\", "/") } @@ -154,6 +158,31 @@ func Load(ctx context.Context, projectFilePath string) (*ProjectConfig, error) { return nil, fmt.Errorf("parsing project file: %w", err) } + projectConfig.Path = filepath.Dir(projectFilePath) + + // complement the project config with hooks defined in the infra path using the syntax `.hooks.yaml` + // for example `main.hooks.yaml` + hooksDefinedAtInfraPath, err := hooksFromInfraModule( + filepath.Join(projectConfig.Path, projectConfig.Infra.Path), + projectConfig.Infra.Module) + if err != nil { + return nil, fmt.Errorf("failed getting hooks from infra path, %w", err) + } + // Merge the hooks defined at the infra path with the hooks defined in the project configuration + if len(hooksDefinedAtInfraPath) > 0 && projectConfig.Hooks == nil { + projectConfig.Hooks = make(map[string][]*ext.HookConfig) + } + for hookName, externalHookList := range hooksDefinedAtInfraPath { + if hookListFromAzureYaml, hookExists := projectConfig.Hooks[hookName]; hookExists { + mergedHooks := make([]*ext.HookConfig, 0, len(hookListFromAzureYaml)+len(externalHookList)) + mergedHooks = append(mergedHooks, hookListFromAzureYaml...) + mergedHooks = append(mergedHooks, externalHookList...) + projectConfig.Hooks[hookName] = mergedHooks + continue + } + projectConfig.Hooks[hookName] = externalHookList + } + if projectConfig.Metadata != nil && projectConfig.Metadata.Template != "" { template := strings.Split(projectConfig.Metadata.Template, "@") if len(template) == 1 { // no version specifier, just the template ID @@ -187,7 +216,6 @@ func Load(ctx context.Context, projectFilePath string) (*ProjectConfig, error) { tracing.SetUsageAttributes(fields.ProjectServiceHostsKey.StringSlice(hosts)) } - projectConfig.Path = filepath.Dir(projectFilePath) return projectConfig, nil } @@ -271,7 +299,28 @@ func Save(ctx context.Context, projectConfig *ProjectConfig, projectFilePath str return fmt.Errorf("saving project file: %w", err) } - projectConfig.Path = projectFilePath + projectConfig.Path = filepath.Dir(projectFilePath) return nil } + +// hooksFromInfraModule check if there is file named azd.hooks.yaml in the service path +// and return the hooks configuration. +func hooksFromInfraModule(infraPath, moduleName string) (HooksConfig, error) { + hooksPath := filepath.Join(infraPath, moduleName+".hooks.yaml") + if _, err := os.Stat(hooksPath); os.IsNotExist(err) { + return nil, nil + } + hooksFile, err := os.ReadFile(hooksPath) + if err != nil { + return nil, fmt.Errorf("failed reading hooks from '%s', %w", hooksPath, err) + } + + // open hooksPath into a byte array and unmarshal it into a map[string]*ext.HookConfig + hooks := make(HooksConfig) + if err := yaml.Unmarshal(hooksFile, &hooks); err != nil { + return nil, fmt.Errorf("failed unmarshalling hooks from '%s', %w", hooksPath, err) + } + + return hooks, nil +} diff --git a/cli/azd/pkg/project/project_test.go b/cli/azd/pkg/project/project_test.go index f3fa036e69c..4aa96f1c278 100644 --- a/cli/azd/pkg/project/project_test.go +++ b/cli/azd/pkg/project/project_test.go @@ -5,6 +5,7 @@ package project import ( "context" + "os" "path/filepath" "testing" @@ -13,7 +14,9 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/azure" "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/ext" "github.com/azure/azure-dev/cli/azd/pkg/infra" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/azure/azure-dev/cli/azd/test/mocks/mockarmresources" "github.com/azure/azure-dev/cli/azd/test/mocks/mockazcli" @@ -342,3 +345,132 @@ services: assert.Equal(t, filepath.FromSlash("src/api"), projectConfig.Services["api"].RelativePath) assert.Equal(t, filepath.FromSlash("bin/api"), projectConfig.Services["api"].OutputPath) } + +func Test_HooksFromFolderPath(t *testing.T) { + t.Run("ProjectInfraHooks", func(t *testing.T) { + prj := &ProjectConfig{ + Name: "minimal", + Services: map[string]*ServiceConfig{}, + } + contents, err := yaml.Marshal(prj) + require.NoError(t, err) + + tempDir := t.TempDir() + + azureYamlPath := filepath.Join(tempDir, "azure.yaml") + err = os.WriteFile(azureYamlPath, contents, osutil.PermissionDirectory) + require.NoError(t, err) + + infraPath := filepath.Join(tempDir, "infra") + err = os.Mkdir(infraPath, osutil.PermissionDirectory) + require.NoError(t, err) + + hooksPath := filepath.Join(infraPath, "main.hooks.yaml") + hooksContent := []byte(` +prebuild: + shell: sh + run: ./pre-build.sh +postbuild: + shell: pwsh + run: ./post-build.ps1 +`) + + err = os.WriteFile(hooksPath, hooksContent, osutil.PermissionDirectory) + require.NoError(t, err) + + expectedHooks := HooksConfig{ + "prebuild": {{ + Name: "", + Shell: ext.ShellTypeBash, + Run: "./pre-build.sh", + ContinueOnError: false, + Interactive: false, + Windows: nil, + Posix: nil, + }}, + "postbuild": {{ + Name: "", + Shell: ext.ShellTypePowershell, + Run: "./post-build.ps1", + ContinueOnError: false, + Interactive: false, + Windows: nil, + Posix: nil, + }, + }} + + project, err := Load(context.Background(), azureYamlPath) + require.NoError(t, err) + require.Equal(t, expectedHooks, project.Hooks) + }) + + t.Run("DoubleDefintionHooks", func(t *testing.T) { + prj := &ProjectConfig{ + Name: "minimal", + Services: map[string]*ServiceConfig{}, + Hooks: HooksConfig{ + "prebuild": {{ + Shell: ext.ShellTypeBash, + Run: "./pre-build.sh", + }}, + }, + } + contents, err := yaml.Marshal(prj) + require.NoError(t, err) + + tempDir := t.TempDir() + + azureYamlPath := filepath.Join(tempDir, "azure.yaml") + err = os.WriteFile(azureYamlPath, contents, osutil.PermissionDirectory) + require.NoError(t, err) + + infraPath := filepath.Join(tempDir, "infra") + err = os.Mkdir(infraPath, osutil.PermissionDirectory) + require.NoError(t, err) + + hooksPath := filepath.Join(infraPath, "main.hooks.yaml") + hooksContent := []byte(` +prebuild: + shell: sh + run: ./pre-build-external.sh +postbuild: + shell: pwsh + run: ./post-build.ps1 +`) + + err = os.WriteFile(hooksPath, hooksContent, osutil.PermissionDirectory) + require.NoError(t, err) + + project, err := Load(context.Background(), azureYamlPath) + require.NoError(t, err) + expectedHooks := HooksConfig{ + "prebuild": {{ + Name: "", + Shell: ext.ShellTypeBash, + Run: "./pre-build.sh", + ContinueOnError: false, + Interactive: false, + Windows: nil, + Posix: nil, + }, { + Name: "", + Shell: ext.ShellTypeBash, + Run: "./pre-build-external.sh", + ContinueOnError: false, + Interactive: false, + Windows: nil, + Posix: nil, + }}, + "postbuild": {{ + Name: "", + Shell: ext.ShellTypePowershell, + Run: "./post-build.ps1", + ContinueOnError: false, + Interactive: false, + Windows: nil, + Posix: nil, + }}, + } + require.Equal(t, expectedHooks, project.Hooks) + }) +}