cli/azd/pkg/project/framework_service_dotnet.go (189 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package project
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/azure/azure-dev/cli/azd/pkg/async"
"github.com/azure/azure-dev/cli/azd/pkg/environment"
"github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning"
"github.com/azure/azure-dev/cli/azd/pkg/tools"
"github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet"
)
const (
defaultDotNetBuildConfiguration string = "Release"
)
type dotnetProject struct {
env *environment.Environment
dotnetCli *dotnet.Cli
}
// NewDotNetProject creates a new instance of a dotnet project
func NewDotNetProject(
dotNetCli *dotnet.Cli,
env *environment.Environment,
) FrameworkService {
return &dotnetProject{
env: env,
dotnetCli: dotNetCli,
}
}
func (dp *dotnetProject) Requirements() FrameworkRequirements {
return FrameworkRequirements{
// dotnet will automatically restore & build the project if needed
Package: FrameworkPackageRequirements{
RequireRestore: false,
RequireBuild: false,
},
}
}
// Gets the required external tools for the project
func (dp *dotnetProject) RequiredExternalTools(_ context.Context, _ *ServiceConfig) []tools.ExternalTool {
return []tools.ExternalTool{dp.dotnetCli}
}
// Initializes the dotnet project
func (dp *dotnetProject) Initialize(ctx context.Context, serviceConfig *ServiceConfig) error {
// NOTE(ellismg): For dotnet based apps, we installed a lifecycle hook that would write all the outputs from a deployment
// into dotnet user-secrets. The goal of this was to make it easy to consume the values from your infrastructure in your
// dotnet app, but the strategy doesn't work well in practice and it ends up being an abuse of the user secrets setup.
//
// We'd like to stop doing this at some point for all .NET projects, but we can make sure that we don't inherit the
// bad behavior for containerized projects, without being concerned about it being considered a breaking change.
if serviceConfig.Host != DotNetContainerAppTarget {
projFile, err := findProjectFile(serviceConfig.Name, serviceConfig.Path())
if err != nil {
return err
}
if err := dp.dotnetCli.InitializeSecret(ctx, projFile); err != nil {
return err
}
handler := func(ctx context.Context, args ServiceLifecycleEventArgs) error {
return dp.setUserSecretsFromOutputs(ctx, serviceConfig, args)
}
if err := serviceConfig.AddHandler(ServiceEventEnvUpdated, handler); err != nil {
return err
}
}
return nil
}
// Restores the dependencies for the project
func (dp *dotnetProject) Restore(
ctx context.Context,
serviceConfig *ServiceConfig,
progress *async.Progress[ServiceProgress],
) (*ServiceRestoreResult, error) {
progress.SetProgress(NewServiceProgress("Restoring .NET project dependencies"))
projFile, err := findProjectFile(serviceConfig.Name, serviceConfig.Path())
if err != nil {
return nil, err
}
if err := dp.dotnetCli.Restore(ctx, projFile); err != nil {
return nil, err
}
return &ServiceRestoreResult{}, nil
}
// Builds the dotnet project using the dotnet CLI
func (dp *dotnetProject) Build(
ctx context.Context,
serviceConfig *ServiceConfig,
restoreOutput *ServiceRestoreResult,
progress *async.Progress[ServiceProgress],
) (*ServiceBuildResult, error) {
progress.SetProgress(NewServiceProgress("Building .NET project"))
projFile, err := findProjectFile(serviceConfig.Name, serviceConfig.Path())
if err != nil {
return nil, err
}
if err := dp.dotnetCli.Build(ctx, projFile, defaultDotNetBuildConfiguration, ""); err != nil {
return nil, err
}
defaultOutputDir := filepath.Join("./bin", defaultDotNetBuildConfiguration)
// Attempt to find the default build output location
buildOutputDir := serviceConfig.Path()
_, err = os.Stat(filepath.Join(buildOutputDir, defaultOutputDir))
if err == nil {
buildOutputDir = filepath.Join(buildOutputDir, defaultOutputDir)
}
// By default dotnet build will create a sub folder for the project framework version, etc. net8.0
// If we have a single folder under build configuration assume this location as build output result
subDirs, err := os.ReadDir(buildOutputDir)
if err == nil {
if len(subDirs) == 1 {
buildOutputDir = filepath.Join(buildOutputDir, subDirs[0].Name())
}
}
return &ServiceBuildResult{
Restore: restoreOutput,
BuildOutputPath: buildOutputDir,
}, nil
}
func (dp *dotnetProject) Package(
ctx context.Context,
serviceConfig *ServiceConfig,
buildOutput *ServiceBuildResult,
progress *async.Progress[ServiceProgress],
) (*ServicePackageResult, error) {
if serviceConfig.Host == DotNetContainerAppTarget {
// TODO(weilim): For containerized projects, we publish the produced container image in a single call
// via `dotnet publish /p:PublishProfile=DefaultContainer`, thus the default `dotnet publish` command
// executed here is not useful.
//
// It's probably right for us to think about "package" for a containerized application as meaning
// "produce the tgz" of the image, as would be done by `docker save`, but this is currently not supported.
//
// See related comment in cmd/package.go.
return &ServicePackageResult{}, nil
}
packageDest, err := os.MkdirTemp("", "azd")
if err != nil {
return nil, fmt.Errorf("creating package directory for %s: %w", serviceConfig.Name, err)
}
progress.SetProgress(NewServiceProgress("Publishing .NET project"))
projFile, err := findProjectFile(serviceConfig.Name, serviceConfig.Path())
if err != nil {
return nil, err
}
if err := dp.dotnetCli.Publish(ctx, projFile, defaultDotNetBuildConfiguration, packageDest); err != nil {
return nil, err
}
if serviceConfig.OutputPath != "" {
packageDest = filepath.Join(packageDest, serviceConfig.OutputPath)
}
if err := validatePackageOutput(packageDest); err != nil {
return nil, err
}
return &ServicePackageResult{
Build: buildOutput,
PackagePath: packageDest,
}, nil
}
func (dp *dotnetProject) setUserSecretsFromOutputs(
ctx context.Context,
serviceConfig *ServiceConfig,
args ServiceLifecycleEventArgs,
) error {
bicepOutputArgs := args.Args["bicepOutput"]
if bicepOutputArgs == nil {
log.Println("no bicep outputs set as secrets to dotnet project, map args.Args doesn't contain key \"bicepOutput\"")
return nil
}
bicepOutput, ok := bicepOutputArgs.(map[string]provisioning.OutputParameter)
if !ok {
return fmt.Errorf("fail on interface conversion: no type in map")
}
secrets := map[string]string{}
for key, val := range bicepOutput {
secrets[normalizeDotNetSecret(key)] = fmt.Sprint(val.Value)
}
if err := dp.dotnetCli.SetSecrets(ctx, secrets, serviceConfig.Path()); err != nil {
return fmt.Errorf("failed to set secrets: %w", err)
}
return nil
}
func normalizeDotNetSecret(key string) string {
// dotnet recognizes "__" as the hierarchy key separator for environment variables, but for user secrets, it has to be
// ":".
return strings.ReplaceAll(key, "__", ":")
}
/* findProjectFile locates the project file to pass to the `dotnet` tool for a given dotnet service.
**
** projectPath is either a path to a directory, or to a project file. When projectPath is a directory,
** the first file matching the glob expression *.*proj (what dotnet expects) is returned.
** If multiple files match, an error is returned.
*/
func findProjectFile(serviceName string, projectPath string) (string, error) {
info, err := os.Stat(projectPath)
if err != nil {
return "", err
}
if !info.IsDir() {
return projectPath, nil
}
files, err := filepath.Glob(filepath.Join(projectPath, "*.*proj"))
if err != nil {
return "", fmt.Errorf("searching for project file: %w", err)
}
if len(files) == 0 {
return "", fmt.Errorf(
"could not locate a dotnet project file for service %s in %s. Update the project setting of "+
"azure.yaml for service %s to be the path to the dotnet project for this service",
serviceName, projectPath, serviceName)
} else if len(files) > 1 {
return "", fmt.Errorf(
"could not locate a dotnet project file for service %s in %s. Multiple project files exist. Update "+
"the \"project\" setting of azure.yaml for service %s to be the path to the dotnet project to use for this "+
"service",
serviceName, projectPath, serviceName)
}
return files[0], nil
}