Skip to content

Support azd hooks out of azure.yaml, Part I of merging azd operation to hooks #4244

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 66 additions & 2 deletions cli/azd/pkg/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,24 @@ 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 azd.hooks files if they exist
hooksDefinedAtInfraPath, err := hooksFromFolderPath(filepath.Join(projectConfig.Path, projectConfig.Infra.Path))
if err != nil {
return nil, fmt.Errorf("failed getting hooks from infra path, %w", err)
}
if len(hooksDefinedAtInfraPath) > 0 && len(projectConfig.Hooks) > 0 {
return nil, fmt.Errorf(
"project hooks defined in both %s and azure.yaml configuration,"+
" please remove one of them",
filepath.Join(projectConfig.Infra.Path, "azd.hooks.yaml"),
)
}
if projectConfig.Hooks == nil {
projectConfig.Hooks = hooksDefinedAtInfraPath
}

if projectConfig.Metadata != nil && projectConfig.Metadata.Template != "" {
template := strings.Split(projectConfig.Metadata.Template, "@")
if len(template) == 1 { // no version specifier, just the template ID
Expand All @@ -153,6 +171,20 @@ func Load(ctx context.Context, projectFilePath string) (*ProjectConfig, error) {
for _, svcConfig := range projectConfig.Services {
hosts[i] = string(svcConfig.Host)
languages[i] = string(svcConfig.Language)

// complement service level hooks
hooksDefinedAtServicePath, err := hooksFromFolderPath(svcConfig.RelativePath)
if err != nil {
return nil, err
}
if svcConfig.Hooks != nil && hooksDefinedAtServicePath != nil {
return nil, fmt.Errorf("service %s has hooks defined in both azd.hooks.yaml and azure.yaml, "+
"please remove one of them.", svcConfig.Name)
}
if svcConfig.Hooks == nil {
svcConfig.Hooks = hooksDefinedAtServicePath
}

i++
}

Expand All @@ -163,7 +195,6 @@ func Load(ctx context.Context, projectFilePath string) (*ProjectConfig, error) {
tracing.SetUsageAttributes(fields.ProjectServiceHostsKey.StringSlice(hosts))
}

projectConfig.Path = filepath.Dir(projectFilePath)
return projectConfig, nil
}

Expand Down Expand Up @@ -221,7 +252,40 @@ 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
}

// hooksFromFolderPath check if there is file named azd.hooks.yaml in the service path
// and return the hooks configuration.
func hooksFromFolderPath(servicePath string) (HooksConfig, error) {
hooksPath := filepath.Join(servicePath, "azd.hooks.yaml")

// due to projects depending on a ProjectImporter like Aspire, we need to ignore all type of errors related to
// the path is either not found, not accessible or any other error that might occur.
// In case of Aspire, the servicePath points out to the C# project, and not to the directory.
// We could handle Aspire Project here but that's not the purpose of this function.
// The right thing might be to use the ProjectImporter and get the list of services from Aspire and check for hooks at
// each path, but hooks for Aspire services are not even supported on azure.yaml.
if _, err := os.Stat(hooksPath); err != nil {
hooksPath = filepath.Join(servicePath, "azd.hooks.yml")
if _, err := os.Stat(hooksPath); err != nil {
log.Println("error trying to read hooks file for service in path: ", servicePath, ". Error:", 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
}
215 changes: 215 additions & 0 deletions cli/azd/pkg/project/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ package project

import (
"context"
"os"
"path/filepath"
"testing"

"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
"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/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"
Expand Down Expand Up @@ -311,3 +315,214 @@ func TestMinimalYaml(t *testing.T) {
})
}
}

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, "azd.hooks.yaml")
hooksContent := []byte(`
pre-build:
shell: sh
run: ./pre-build.sh
post-build:
shell: pwsh
run: ./post-build.ps1
`)

err = os.WriteFile(hooksPath, hooksContent, osutil.PermissionDirectory)
require.NoError(t, err)

expectedHooks := HooksConfig{
"pre-build": {{
Name: "",
Shell: ext.ShellTypeBash,
Run: "./pre-build.sh",
ContinueOnError: false,
Interactive: false,
Windows: nil,
Posix: nil,
}},
"post-build": {{
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("ErrorDoubleDefintionHooks", func(t *testing.T) {
prj := &ProjectConfig{
Name: "minimal",
Services: map[string]*ServiceConfig{},
Hooks: HooksConfig{
"prebuild": {{
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, "azd.hooks.yaml")
hooksContent := []byte(`
pre-build:
shell: sh
run: ./pre-build.sh
post-build:
shell: pwsh
run: ./post-build.ps1
`)

err = os.WriteFile(hooksPath, hooksContent, osutil.PermissionDirectory)
require.NoError(t, err)

project, err := Load(context.Background(), azureYamlPath)
require.Error(t, err)
var expectedProject *ProjectConfig
require.Equal(t, expectedProject, project)
})

t.Run("ServiceInfraHooks", func(t *testing.T) {
tempDir := t.TempDir()

prj := &ProjectConfig{
Name: "minimal",
Services: map[string]*ServiceConfig{
"api": {
Name: "api",
Host: AppServiceTarget,
RelativePath: filepath.Join(tempDir, "api"),
},
},
}
contents, err := yaml.Marshal(prj)
require.NoError(t, err)

azureYamlPath := filepath.Join(tempDir, "azure.yaml")
err = os.WriteFile(azureYamlPath, contents, osutil.PermissionDirectory)
require.NoError(t, err)

servicePath := filepath.Join(tempDir, "api")
err = os.Mkdir(servicePath, osutil.PermissionDirectory)
require.NoError(t, err)

hooksPath := filepath.Join(servicePath, "azd.hooks.yaml")
hooksContent := []byte(`
pre-build:
shell: sh
run: ./pre-build.sh
post-build:
shell: pwsh
run: ./post-build.ps1
`)

err = os.WriteFile(hooksPath, hooksContent, osutil.PermissionDirectory)
require.NoError(t, err)

expectedHooks := HooksConfig{
"pre-build": {{
Name: "",
Shell: ext.ShellTypeBash,
Run: "./pre-build.sh",
ContinueOnError: false,
Interactive: false,
Windows: nil,
Posix: nil,
}},
"post-build": {{
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.Services["api"].Hooks)
})

t.Run("ErrorDoubleDefintionServiceHooks", func(t *testing.T) {
tempDir := t.TempDir()
prj := &ProjectConfig{
Name: "minimal",
Services: map[string]*ServiceConfig{
"api": {
Name: "api",
Host: AppServiceTarget,
Hooks: HooksConfig{
"prebuild": {{
Run: "./pre-build.sh",
}},
},
RelativePath: filepath.Join(tempDir, "api"),
},
},
}
contents, err := yaml.Marshal(prj)
require.NoError(t, err)

azureYamlPath := filepath.Join(tempDir, "azure.yaml")
err = os.WriteFile(azureYamlPath, contents, osutil.PermissionDirectory)
require.NoError(t, err)

servicePath := filepath.Join(tempDir, "api")
err = os.Mkdir(servicePath, osutil.PermissionDirectory)
require.NoError(t, err)

hooksPath := filepath.Join(servicePath, "azd.hooks.yaml")
hooksContent := []byte(`
pre-build:
shell: sh
run: ./pre-build.sh
post-build:
shell: pwsh
run: ./post-build.ps1
`)

err = os.WriteFile(hooksPath, hooksContent, osutil.PermissionDirectory)
require.NoError(t, err)

project, err := Load(context.Background(), azureYamlPath)
require.Error(t, err)
var expectedProject *ProjectConfig
require.Equal(t, expectedProject, project)
})
}
Loading