cli/azd/pkg/project/dotnet_importer.go (494 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package project
import (
"context"
"errors"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"sync"
"github.com/azure/azure-dev/cli/azd/internal/scaffold"
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
"github.com/azure/azure-dev/cli/azd/pkg/apphost"
"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/provisioning"
"github.com/azure/azure-dev/cli/azd/pkg/input"
"github.com/azure/azure-dev/cli/azd/pkg/lazy"
"github.com/azure/azure-dev/cli/azd/pkg/osutil"
"github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet"
"github.com/psanford/memfs"
)
type hostCheckResult struct {
is bool
err error
}
// DotNetImporter is an importer that is able to import projects and infrastructure from a manifest produced by a .NET App.
type DotNetImporter struct {
dotnetCli *dotnet.Cli
console input.Console
lazyEnv *lazy.Lazy[*environment.Environment]
lazyEnvManager *lazy.Lazy[environment.Manager]
alphaFeatureManager *alpha.FeatureManager
// TODO(ellismg): This cache exists because we end up needing the same manifest multiple times for a single logical
// operation and it is expensive to generate. We should consider if this is the correct location for the cache or if
// it should be in some higher level component. Right now the lifetime issues are not too large of a deal, since
// `azd` processes are short lived.
cache map[manifestCacheKey]*apphost.Manifest
cacheMu sync.Mutex
hostCheck map[string]hostCheckResult
hostCheckMu sync.Mutex
}
// manifestCacheKey is the key we use when caching manifests. It is a combination of the project path and the
// DOTNET_ENVIRONMENT value (which can influence manifest generation)
type manifestCacheKey struct {
projectPath string
dotnetEnvironment string
}
func NewDotNetImporter(
dotnetCli *dotnet.Cli,
console input.Console,
lazyEnv *lazy.Lazy[*environment.Environment],
lazyEnvManager *lazy.Lazy[environment.Manager],
alphaFeatureManager *alpha.FeatureManager,
) *DotNetImporter {
return &DotNetImporter{
dotnetCli: dotnetCli,
console: console,
lazyEnv: lazyEnv,
lazyEnvManager: lazyEnvManager,
alphaFeatureManager: alphaFeatureManager,
cache: make(map[manifestCacheKey]*apphost.Manifest),
hostCheck: make(map[string]hostCheckResult),
}
}
// CanImport returns true when the given project can be imported by this importer. Only some .NET Apps are able
// to produce the manifest that importer expects.
func (ai *DotNetImporter) CanImport(ctx context.Context, projectPath string) (bool, error) {
ai.hostCheckMu.Lock()
defer ai.hostCheckMu.Unlock()
if v, has := ai.hostCheck[projectPath]; has {
return v.is, v.err
}
isAppHost, err := ai.dotnetCli.IsAspireHostProject(ctx, projectPath)
if err != nil {
ai.hostCheck[projectPath] = hostCheckResult{
is: false,
err: err,
}
return false, err
}
ai.hostCheck[projectPath] = hostCheckResult{
is: isAppHost,
err: nil,
}
return isAppHost, nil
}
func (ai *DotNetImporter) ProjectInfrastructure(ctx context.Context, svcConfig *ServiceConfig) (*Infra, error) {
manifest, err := ai.ReadManifest(ctx, svcConfig)
if err != nil {
return nil, fmt.Errorf("generating app host manifest: %w", err)
}
azdOperationsEnabled := ai.alphaFeatureManager.IsEnabled(provisioning.AzdOperationsFeatureKey)
files, err := apphost.BicepTemplate("main", manifest, apphost.AppHostOptions{
AzdOperations: azdOperationsEnabled,
})
if err != nil {
if errors.Is(err, provisioning.ErrAzdOperationsNotEnabled) {
// Use a warning for this error about azd operations is required for the current project to fully work
ai.console.Message(ctx, err.Error())
} else {
return nil, fmt.Errorf("generating bicep from manifest: %w", err)
}
}
tmpDir, err := os.MkdirTemp("", "azd-infra")
if err != nil {
return nil, fmt.Errorf("creating temporary directory: %w", err)
}
err = fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
target := filepath.Join(tmpDir, path)
if err := os.MkdirAll(filepath.Dir(target), osutil.PermissionDirectoryOwnerOnly); err != nil {
return err
}
contents, err := fs.ReadFile(files, path)
if err != nil {
return err
}
return os.WriteFile(target, contents, osutil.PermissionFile)
})
if err != nil {
return nil, fmt.Errorf("writing infrastructure: %w", err)
}
return &Infra{
Options: provisioning.Options{
Provider: provisioning.Bicep,
Path: tmpDir,
Module: DefaultModule,
},
cleanupDir: tmpDir,
}, nil
}
// mapToStringSlice converts a map of strings to a slice of strings.
// Each key-value pair in the map is converted to a string in the format "key:value",
// where the separator is specified by the `separator` parameter.
// If the value is an empty string, only the key is included in the resulting slice.
// The resulting slice is returned.
func mapToStringSlice(m map[string]string, separator string) []string {
var result []string
for key, value := range m {
if value == "" {
result = append(result, key)
} else {
result = append(result, key+separator+value)
}
}
return result
}
// mapToExpandableStringSlice converts a map of strings to a slice of expandable strings.
// Each key-value pair in the map is converted to a string in the format "key:value",
// where the separator is specified by the `separator` parameter.
// If the value is an empty string, only the key is included in the resulting slice.
// The resulting slice is returned without any string interpolation performed.
func mapToExpandableStringSlice(m map[string]string, separator string) []osutil.ExpandableString {
var result []osutil.ExpandableString
for key, value := range m {
if value == "" {
result = append(result, osutil.NewExpandableString(key))
} else {
result = append(result, osutil.NewExpandableString(key+separator+value))
}
}
return result
}
func (ai *DotNetImporter) Services(
ctx context.Context, p *ProjectConfig, svcConfig *ServiceConfig,
) (map[string]*ServiceConfig, error) {
services := make(map[string]*ServiceConfig)
manifest, err := ai.ReadManifest(ctx, svcConfig)
if err != nil {
return nil, fmt.Errorf("generating app host manifest: %w", err)
}
projects := apphost.ProjectPaths(manifest)
for name, path := range projects {
relPath, err := filepath.Rel(p.Path, path)
if err != nil {
return nil, err
}
// TODO(ellismg): Some of this code is duplicated from project.Parse, we should centralize this logic long term.
svc := &ServiceConfig{
RelativePath: relPath,
Language: ServiceLanguageDotNet,
Host: DotNetContainerAppTarget,
}
svc.Name = name
svc.Project = p
svc.EventDispatcher = ext.NewEventDispatcher[ServiceLifecycleEventArgs]()
svc.Infra.Provider, err = provisioning.ParseProvider(svc.Infra.Provider)
if err != nil {
return nil, fmt.Errorf("parsing service %s: %w", svc.Name, err)
}
svc.DotNetContainerApp = &DotNetContainerAppOptions{
Manifest: manifest,
ProjectName: name,
AppHostPath: svcConfig.Path(),
}
services[svc.Name] = svc
}
dockerfiles := apphost.Dockerfiles(manifest)
for name, dockerfile := range dockerfiles {
relPath, err := filepath.Rel(p.Path, filepath.Dir(dockerfile.Path))
if err != nil {
return nil, err
}
// TODO(ellismg): Some of this code is duplicated from project.Parse, we should centralize this logic long term.
svc := &ServiceConfig{
RelativePath: relPath,
Language: ServiceLanguageDocker,
Host: DotNetContainerAppTarget,
Docker: DockerProjectOptions{
Path: dockerfile.Path,
Context: dockerfile.Context,
BuildArgs: mapToExpandableStringSlice(dockerfile.BuildArgs, "="),
},
}
svc.Name = name
svc.Project = p
svc.EventDispatcher = ext.NewEventDispatcher[ServiceLifecycleEventArgs]()
svc.Infra.Provider, err = provisioning.ParseProvider(svc.Infra.Provider)
if err != nil {
return nil, fmt.Errorf("parsing service %s: %w", svc.Name, err)
}
svc.DotNetContainerApp = &DotNetContainerAppOptions{
Manifest: manifest,
ProjectName: name,
AppHostPath: svcConfig.Path(),
}
services[svc.Name] = svc
}
containers := apphost.Containers(manifest)
for name, container := range containers {
// TODO(ellismg): Some of this code is duplicated from project.Parse, we should centralize this logic long term.
svc := &ServiceConfig{
RelativePath: svcConfig.RelativePath,
Language: ServiceLanguageDotNet,
Host: DotNetContainerAppTarget,
}
svc.Name = name
svc.Project = p
svc.EventDispatcher = ext.NewEventDispatcher[ServiceLifecycleEventArgs]()
svc.Infra.Provider, err = provisioning.ParseProvider(svc.Infra.Provider)
if err != nil {
return nil, fmt.Errorf("parsing service %s: %w", svc.Name, err)
}
svc.DotNetContainerApp = &DotNetContainerAppOptions{
ContainerImage: container.Image,
Manifest: manifest,
ProjectName: name,
AppHostPath: svcConfig.Path(),
}
services[svc.Name] = svc
}
buildContainers, err := apphost.BuildContainers(manifest)
if err != nil {
return nil, err
}
for name, bContainer := range buildContainers {
defaultLanguage := ServiceLanguageDotNet
relativePath := svcConfig.RelativePath
var dOptions DockerProjectOptions
if bContainer.Build != nil {
defaultLanguage = ServiceLanguageDocker
relPath, err := filepath.Rel(p.Path, filepath.Dir(bContainer.Build.Dockerfile))
if err != nil {
return nil, err
}
relativePath = relPath
bArgs, err := evaluateArgsWithConfig(*manifest, bContainer.Build.Args)
if err != nil {
return nil, fmt.Errorf("evaluating build args for service %s: %w", name, err)
}
bArgsArray, reqEnv, err := buildArgsArrayAndEnv(*manifest, bContainer.Build.Secrets)
if err != nil {
return nil, fmt.Errorf("converting build args to array for service %s: %w", name, err)
}
dOptions = DockerProjectOptions{
Path: bContainer.Build.Dockerfile,
Context: bContainer.Build.Context,
BuildArgs: mapToExpandableStringSlice(bArgs, "="),
BuildSecrets: bArgsArray,
BuildEnv: reqEnv,
}
}
svc := &ServiceConfig{
RelativePath: relativePath,
Language: defaultLanguage,
Host: DotNetContainerAppTarget,
Docker: dOptions,
}
svc.Name = name
svc.Project = p
svc.EventDispatcher = ext.NewEventDispatcher[ServiceLifecycleEventArgs]()
svc.Infra.Provider, err = provisioning.ParseProvider(svc.Infra.Provider)
if err != nil {
return nil, fmt.Errorf("parsing service %s: %w", svc.Name, err)
}
svc.DotNetContainerApp = &DotNetContainerAppOptions{
ContainerImage: bContainer.Image,
Manifest: manifest,
ProjectName: name,
AppHostPath: svcConfig.Path(),
}
services[svc.Name] = svc
}
return services, nil
}
// buildArgsArray produces an array of args to pass to the container build command.
// See: https://docs.docker.com/build/building/secrets/
func buildArgsArrayAndEnv(
manifest apphost.Manifest,
bArgs map[string]apphost.ContainerV1BuildSecrets) ([]string, []string, error) {
var result []string
var reqEnv []string
for bArgKey, bArg := range bArgs {
if bArg.Type != "env" && bArg.Type != "file" {
return nil, nil, fmt.Errorf("unsupported secret type %q for build arg %q", bArg.Type, bArgKey)
}
baseArg := fmt.Sprintf("id=%s", bArgKey)
if bArg.Type == "file" {
if bArg.Source == nil {
return nil, nil, fmt.Errorf("missing source for file secret %q", bArgKey)
}
baseArg = fmt.Sprintf("id=%s,src=%s", bArgKey, *bArg.Source)
}
if bArg.Type == "env" {
if bArg.Value == nil {
return nil, nil, fmt.Errorf("missing value for env secret %q", bArgKey)
}
bArgValue, err := evaluateExpressions(*bArg.Value, manifest)
if err != nil {
return nil, nil, fmt.Errorf("evaluating value for env secret %q: %w", bArgKey, err)
}
reqEnv = append(reqEnv, fmt.Sprintf("%s=%s", bArgKey, bArgValue))
}
result = append(result, baseArg)
}
return result, reqEnv, nil
}
func evaluateArgsWithConfig(
manifest apphost.Manifest, args map[string]string) (map[string]string, error) {
result := make(map[string]string, len(args))
for argKey, argValue := range args {
evaluatedValue, err := evaluateExpressions(argValue, manifest)
if err != nil {
return nil, err
}
result[argKey] = evaluatedValue
}
return result, nil
}
func evaluateExpressions(source string, manifest apphost.Manifest) (string, error) {
return apphost.EvalString(source, func(match string) (string, error) {
return evaluateSingleExpressionMatch(match, manifest)
})
}
func evaluateSingleExpressionMatch(
match string, manifest apphost.Manifest) (string, error) {
exp := match
resourceAndPath := strings.SplitN(exp, ".", 2)
if len(resourceAndPath) != 2 {
return match, fmt.Errorf("invalid expression %q. Expecting the form of: resource.value", exp)
}
resourceName := resourceAndPath[0]
resource, has := manifest.Resources[resourceName]
if !has {
return match, fmt.Errorf("resource %q not found in manifest", resourceName)
}
if resource.Type != "parameter.v0" {
return match, fmt.Errorf(
"resource %q is not a parameter. Only parameters are supported for build args expressions",
resourceName)
}
inputParam, err := apphost.InputParameter(resourceName, resource)
if err != nil {
return match, fmt.Errorf("getting input parameter for resource %q: %w", resourceName, err)
}
if inputParam == nil {
// parameter not using inputs, has a constant value, use it
return resource.Value, nil
}
fromEnvVar := strings.TrimSuffix(scaffold.EnvFormat(resourceName)[2:], "}")
if valueInEnv := os.Getenv(fromEnvVar); valueInEnv != "" {
log.Println("Using value from environment variable", fromEnvVar, "for parameter", resourceName)
return valueInEnv, nil
}
// can't resolve the parameter here yet, best we can do is resolve the name of the parameter, removing the path
// of the resource from the expression, keeping only the name of the expected parameter.
// The parameter might not be requested at this point, because it could be
// the first time azd is running for the project.
return fmt.Sprintf("{%s%s}", infraParametersKey, resourceName), nil
}
func (ai *DotNetImporter) SynthAllInfrastructure(ctx context.Context, p *ProjectConfig, svcConfig *ServiceConfig,
) (fs.FS, error) {
manifest, err := ai.ReadManifest(ctx, svcConfig)
if err != nil {
return nil, fmt.Errorf("generating apphost manifest: %w", err)
}
generatedFS := memfs.New()
rootModuleName := DefaultModule
if p.Infra.Module != "" {
rootModuleName = p.Infra.Module
}
azdOperationsEnabled := ai.alphaFeatureManager.IsEnabled(provisioning.AzdOperationsFeatureKey)
infraFS, err := apphost.BicepTemplate(rootModuleName, manifest, apphost.AppHostOptions{
AzdOperations: azdOperationsEnabled,
})
if err != nil {
if errors.Is(err, provisioning.ErrAzdOperationsNotEnabled) {
// Use a warning for this error about azd operations is required for the current project to fully work
ai.console.Message(ctx, err.Error())
} else {
return nil, fmt.Errorf("generating infra/ folder: %w", err)
}
}
infraPathPrefix := DefaultPath
if p.Infra.Path != "" {
infraPathPrefix = p.Infra.Path
}
err = fs.WalkDir(infraFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
err = generatedFS.MkdirAll(filepath.Join(infraPathPrefix, filepath.Dir(path)), osutil.PermissionDirectoryOwnerOnly)
if err != nil {
return err
}
contents, err := fs.ReadFile(infraFS, path)
if err != nil {
return err
}
return generatedFS.WriteFile(filepath.Join(infraPathPrefix, path), contents, d.Type().Perm())
})
if err != nil {
return nil, err
}
// Use canonical paths for Rel comparison due to absolute paths provided by ManifestFromAppHost
// being possibly symlinked paths.
root, err := filepath.EvalSymlinks(p.Path)
if err != nil {
return nil, err
}
// writeManifestForResource writes the containerApp.tmpl.yaml or containerApp.bicepparam for the given resource to the
// generated filesystem. The manifest is written to a file name "containerApp.tmpl.yaml" or
// "containerApp.tmpl.bicepparam" in the same directory as the project that produces the
// container we will deploy.
writeManifestForResource := func(name string) error {
normalPath, err := filepath.EvalSymlinks(svcConfig.Path())
if err != nil {
return err
}
projectRelPath, err := filepath.Rel(root, normalPath)
if err != nil {
return err
}
containerAppManifest, manifestType, err := apphost.ContainerAppManifestTemplateForProject(
manifest, name, apphost.AppHostOptions{})
if err != nil {
return fmt.Errorf("generating containerApp deployment manifest for resource %s: %w", name, err)
}
manifestPath := filepath.Join(filepath.Dir(projectRelPath), "infra", fmt.Sprintf("%s.tmpl.yaml", name))
if manifestType == apphost.ContainerAppManifestTypeBicep {
manifestPath = filepath.Join(
filepath.Dir(projectRelPath), "infra", name, fmt.Sprintf("%s.tmpl.bicepparam", name))
}
if err := generatedFS.MkdirAll(filepath.Dir(manifestPath), osutil.PermissionDirectoryOwnerOnly); err != nil {
return err
}
err = generatedFS.WriteFile(manifestPath, []byte(containerAppManifest), osutil.PermissionFileOwnerOnly)
if err != nil {
return err
}
return nil
}
for name := range apphost.ProjectPaths(manifest) {
if err := writeManifestForResource(name); err != nil {
return nil, err
}
}
for name := range apphost.Dockerfiles(manifest) {
if err := writeManifestForResource(name); err != nil {
return nil, err
}
}
for name := range apphost.Containers(manifest) {
if err := writeManifestForResource(name); err != nil {
return nil, err
}
}
bcs, err := apphost.BuildContainers(manifest)
if err != nil {
return nil, err
}
for name := range bcs {
if err := writeManifestForResource(name); err != nil {
return nil, err
}
}
return generatedFS, nil
}
// ReadManifest reads the manifest for the given app host service, and caches the result.
func (ai *DotNetImporter) ReadManifest(ctx context.Context, svcConfig *ServiceConfig) (*apphost.Manifest, error) {
ai.cacheMu.Lock()
defer ai.cacheMu.Unlock()
var dotnetEnv string
if env, err := ai.lazyEnv.GetValue(); err == nil {
dotnetEnv = env.Getenv("DOTNET_ENVIRONMENT")
}
cacheKey := manifestCacheKey{
projectPath: svcConfig.Path(),
dotnetEnvironment: dotnetEnv,
}
if cached, has := ai.cache[cacheKey]; has {
return cached, nil
}
ai.console.ShowSpinner(ctx, "Analyzing Aspire Application (this might take a moment...)", input.Step)
manifest, err := apphost.ManifestFromAppHost(ctx, svcConfig.Path(), ai.dotnetCli, dotnetEnv)
ai.console.StopSpinner(ctx, "", input.Step)
if err != nil {
return nil, err
}
ai.cache[cacheKey] = manifest
return manifest, nil
}