cli/azd/pkg/project/project.go (249 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package project
import (
"bytes"
"context"
"fmt"
"log"
"os"
"path/filepath"
"slices"
"strings"
"github.com/azure/azure-dev/cli/azd/internal"
"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/config"
"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/osutil"
"github.com/blang/semver/v4"
"github.com/braydonk/yaml"
)
func New(ctx context.Context, projectFilePath string, projectName string) (*ProjectConfig, error) {
newProject := &ProjectConfig{
Name: projectName,
}
err := Save(ctx, newProject, projectFilePath)
if err != nil {
return nil, fmt.Errorf("marshaling project file to yaml: %w", err)
}
return Load(ctx, projectFilePath)
}
// Parse will parse a project from a yaml string and return the project configuration
func Parse(ctx context.Context, yamlContent string) (*ProjectConfig, error) {
var projectConfig ProjectConfig
if strings.TrimSpace(yamlContent) == "" {
return nil, fmt.Errorf("unable to parse azure.yaml file. File is empty.")
}
if err := yaml.Unmarshal([]byte(yamlContent), &projectConfig); err != nil {
return nil, fmt.Errorf(
"unable to parse azure.yaml file. Check the format of the file, "+
"and also verify you have the latest version of the CLI: %w",
err,
)
}
projectConfig.EventDispatcher = ext.NewEventDispatcher[ProjectLifecycleEventArgs]()
if projectConfig.RequiredVersions != nil && projectConfig.RequiredVersions.Azd != nil {
supportedRange, err := semver.ParseRange(*projectConfig.RequiredVersions.Azd)
if err != nil {
return nil, fmt.Errorf("%s is not a valid semver range (for requiredVersions.azd): %w",
*projectConfig.RequiredVersions.Azd, err)
}
if !internal.IsDevVersion() && !supportedRange(internal.VersionInfo().Version) {
return nil, fmt.Errorf("this project requires a version of azd within the range '%s', but you have '%s'. "+
"Visit https://aka.ms/azure-dev/install to install a supported version.",
*projectConfig.RequiredVersions.Azd,
internal.VersionInfo().Version.String())
}
}
var err error
projectConfig.Infra.Provider, err = provisioning.ParseProvider(projectConfig.Infra.Provider)
if err != nil {
return nil, fmt.Errorf("parsing project %s: %w", projectConfig.Name, err)
}
if projectConfig.Infra.Path == "" {
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, "\\", "/")
}
projectConfig.Infra.Path = filepath.FromSlash(projectConfig.Infra.Path)
for key, svc := range projectConfig.Services {
svc.Name = key
svc.Project = &projectConfig
svc.EventDispatcher = ext.NewEventDispatcher[ServiceLifecycleEventArgs]()
var err error
svc.Language, err = parseServiceLanguage(svc.Language)
if err != nil {
return nil, fmt.Errorf("parsing service %s: %w", svc.Name, err)
}
svc.Host, err = parseServiceHost(svc.Host)
if err != nil {
return nil, fmt.Errorf("parsing service %s: %w", svc.Name, err)
}
svc.Infra.Provider, err = provisioning.ParseProvider(svc.Infra.Provider)
if err != nil {
return nil, fmt.Errorf("parsing service %s: %w", svc.Name, err)
}
if strings.Contains(svc.Infra.Path, "\\") && !strings.Contains(svc.Infra.Path, "/") {
svc.Infra.Path = strings.ReplaceAll(svc.Infra.Path, "\\", "/")
}
svc.Infra.Path = filepath.FromSlash(svc.Infra.Path)
// TODO: Move parsing/validation requirements for service targets into their respective components.
// When working within container based applications users may be using external/pre-built images instead of source
// In this case it is valid to have not specified a language but would be required to specify a source image
if svc.Host == ContainerAppTarget && svc.Language == ServiceLanguageNone && svc.Image.Empty() {
return nil, fmt.Errorf("parsing service %s: must specify language or image", svc.Name)
}
if strings.ContainsRune(svc.RelativePath, '\\') && !strings.ContainsRune(svc.RelativePath, '/') {
svc.RelativePath = strings.ReplaceAll(svc.RelativePath, "\\", "/")
}
svc.RelativePath = filepath.FromSlash(svc.RelativePath)
if strings.ContainsRune(svc.OutputPath, '\\') && !strings.ContainsRune(svc.OutputPath, '/') {
svc.OutputPath = strings.ReplaceAll(svc.OutputPath, "\\", "/")
}
svc.OutputPath = filepath.FromSlash(svc.OutputPath)
}
for key, svc := range projectConfig.Resources {
svc.Name = key
svc.Project = &projectConfig
}
return &projectConfig, nil
}
// Load hydrates the azure.yaml configuring into an viewable structure
// This does not evaluate any tooling
func Load(ctx context.Context, projectFilePath string) (*ProjectConfig, error) {
log.Printf("Reading project from file '%s'\n", projectFilePath)
bytes, err := os.ReadFile(projectFilePath)
if err != nil {
return nil, fmt.Errorf("reading project file: %w", err)
}
yaml := string(bytes)
projectConfig, err := Parse(ctx, yaml)
if err != nil {
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 `<moduleName>.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
tracing.SetUsageAttributes(fields.StringHashed(fields.ProjectTemplateIdKey, template[0]))
} else if len(template) == 2 { // templateID@version
tracing.SetUsageAttributes(fields.StringHashed(fields.ProjectTemplateIdKey, template[0]))
tracing.SetUsageAttributes(fields.StringHashed(fields.ProjectTemplateVersionKey, template[1]))
} else { // unknown format, just send the whole thing
tracing.SetUsageAttributes(fields.StringHashed(fields.ProjectTemplateIdKey, projectConfig.Metadata.Template))
}
}
if projectConfig.Name != "" {
tracing.SetUsageAttributes(fields.StringHashed(fields.ProjectNameKey, projectConfig.Name))
}
if projectConfig.Services != nil {
hosts := make([]string, len(projectConfig.Services))
languages := make([]string, len(projectConfig.Services))
i := 0
for _, svcConfig := range projectConfig.Services {
hosts[i] = string(svcConfig.Host)
languages[i] = string(svcConfig.Language)
i++
}
slices.Sort(hosts)
slices.Sort(languages)
tracing.SetUsageAttributes(fields.ProjectServiceLanguagesKey.StringSlice(languages))
tracing.SetUsageAttributes(fields.ProjectServiceHostsKey.StringSlice(hosts))
}
return projectConfig, nil
}
func LoadConfig(ctx context.Context, projectFilePath string) (config.Config, error) {
log.Printf("Reading project from file '%s'\n", projectFilePath)
bytes, err := os.ReadFile(projectFilePath)
if err != nil {
return nil, fmt.Errorf("reading project file: %w", err)
}
yamlContent := string(bytes)
rawConfig := map[string]any{}
if err := yaml.Unmarshal([]byte(yamlContent), &rawConfig); err != nil {
return nil, fmt.Errorf(
"unable to parse azure.yaml file. Check the format of the file, "+
"and also verify you have the latest version of the CLI: %w",
err,
)
}
return config.NewConfig(rawConfig), nil
}
func SaveConfig(ctx context.Context, config config.Config, projectFilePath string) error {
projectBytes, err := yaml.Marshal(config.Raw())
if err != nil {
return fmt.Errorf("marshalling project yaml: %w", err)
}
projectConfig, err := Parse(ctx, string(projectBytes))
if err != nil {
return fmt.Errorf("parsing project yaml: %w", err)
}
return Save(ctx, projectConfig, projectFilePath)
}
// Saves the current instance back to the azure.yaml file
func Save(ctx context.Context, projectConfig *ProjectConfig, projectFilePath string) error {
// We store paths at runtime with os native separators, but want to normalize paths to use forward slashes
// before saving so `azure.yaml` is consistent across platforms. To avoid mutating the original projectConfig,
// we make a copy.
copy := *projectConfig
copy.Infra.Path = filepath.ToSlash(copy.Infra.Path)
copy.Services = make(map[string]*ServiceConfig, len(projectConfig.Services))
for name, svc := range projectConfig.Services {
svcCopy := *svc
svcCopy.Project = ©
svcCopy.Infra.Path = filepath.ToSlash(svc.Infra.Path)
svcCopy.RelativePath = filepath.ToSlash(svc.RelativePath)
svcCopy.OutputPath = filepath.ToSlash(svc.OutputPath)
copy.Services[name] = &svcCopy
}
projectBytes, err := yaml.Marshal(copy)
if err != nil {
return fmt.Errorf("marshalling project yaml: %w", err)
}
version := "v1.0"
if projectConfig.MetaSchemaVersion != "" {
version = projectConfig.MetaSchemaVersion
}
annotation := fmt.Sprintf(
"# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/%s/azure.yaml.json",
version)
projectFileContents := bytes.NewBufferString(annotation + "\n\n")
_, err = projectFileContents.Write(projectBytes)
if err != nil {
return fmt.Errorf("preparing new project file contents: %w", err)
}
err = os.WriteFile(projectFilePath, projectFileContents.Bytes(), osutil.PermissionFile)
if err != nil {
return fmt.Errorf("saving project file: %w", err)
}
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
}